深入理解JAVA虚拟机

初探JVM的内存结构

众所周知,虚拟机的内存可以划分为:运行时数据区和直接内存(堆外内存),而运行时数据区又可以划分为方法区、堆、虚拟机栈、本地方法栈和程序计算器。其中方法区和堆是线程共享的,虚拟机栈、本地方法栈和程序计数器则是线程私有的,每个线程都有一份。
在这里插入图片描述
为了更清晰的看到内存结构,我们需要借助两个jdk的工具:jps和HSDB,jps可以查看到java的进程,HSDB可以看到进程里面的内存详细的信息。首先写个java的demo来测试一下:

/**
 * VM参数
 * -Xms30m -Xmx30m -XX:MaxMetaspaceSize=30m 
 * -XX:+UseConcMarkSweepGC  //使用CMS垃圾回收器
 * -XX:+UseCompressedOops  //禁用指针压缩 
 */
public class JVMObject {
    private final static String MAN_TYPE = "man";//常量
    private static String WOMAN_TYPE = "womain";//静态变量

    public static void main(String[] args) throws InterruptedException {
        //定义一个男人
        Person man = new Person();
        man.setAge(0);
        man.setName("man");
        man.setSexType(MAN_TYPE);

        for (int i = 0; i < 15; i++) {
            System.gc();//手动调用gc,目的为了让man晋升到老年代(正常业务代码不建议这么做)
            man.setAge(man.getAge() + 1);
        }

        //定义一个女人
        Person woman = new Person();
        woman.setAge(0);
        woman.setName("woman");
        woman.setSexType(WOMAN_TYPE);

        Thread.sleep(Integer.MAX_VALUE);//为了让我们的程序不退出,所以让它一直睡眠
    }

    static class Person {
        String name;
        String sexType;
        int age;

        public String getName() {
            return name;
        }

        public void setName(String name) {
            this.name = name;
        }

        public String getSexType() {
            return sexType;
        }

        public void setSexType(String sexType) {
            this.sexType = sexType;
        }

        public int getAge() {
            return age;
        }

        public void setAge(int age) {
            this.age = age;
        }
    }
}

这段代码先new一个man对象,然后手动触发15次gc,然后再new一个woman对象,看看对象的分布情况。现在我们把这段代码跑起来,然后打开cmd界面使用jps查看这个demo的进程。
在这里插入图片描述

通过jps命令我们可以看到我们的进程id是18212,然后再使用HSDB工具关联到这个进程,HSDB可以通过:java -classpath “%JAVA_HOME%/lib/sa-jdi.jar” sun.jvm.hotspot.HSDB命令来启动。启动以后通过File-AttachtoHotSpotProcess选项关联到18212进程:
在这里插入图片描述
关联以后首先可以看到的是这个进程的线程信息:
在这里插入图片描述
这里可以清晰地看到我们的main方法线程,我们就通过这个线程看看JVM里面的线程到底是不是和上面说的一样有虚拟机栈、本地方法栈和程序计算器:
在这里插入图片描述

通过main线程可以看到这个线程栈有两块区域,通过观察上面那块有个static的关键字眼,说明这块区域就是我们上面说的本地方法栈。而下面那块区域有个main的关键字,不用想都知道这就是我们常说的虚拟机栈,而这里运行的是我们的main方法,所以就是我们的main方法栈。
接着我们借助HSDB的Tools栏下的Object Histogram工具可以看的所有的对象信息:
在这里插入图片描述
这里也可以通过包名查找到想要看到的对象:
在这里插入图片描述
然后通过Inspector按钮可以内联到指定的对象
在这里插入图片描述
在这里插入图片描述

通过内联可以看到我们的man对象经过15次gc后,对象年龄已经是15了,然后它的内存地址是0x00000000feca6ab0,而woman对象的内存地址是0x00000000fe200000。
然后再通过JHSDB的Tools栏下的Heap Parameters按钮来查看堆的情况:
在这里插入图片描述
可以看到被划分了两块区域,Gen 0(新生代)和Gen 1(老年代),而Gen 0又被划分为eden区、form区和to区。新生代的内存地址是:0x00000000fe200000,0x00000000fe251f00 - 0x00000000fec00000,而老年代的内存地址是:0x00000000fec00000- 0x0000000100000000。所以man对象(0x00000000feca6ab0)所在的区域是在老年代,而woman对象(0x00000000fe200000)所在的区域是新生代的eden区(说明创建出来的对象会先分配到eden区)。堆是JVM上最大的一块内存区域,我们申请创建的绝大部分对象都分配在堆上,还有一小部分对象会经过逃逸分析给配到栈上。

通过HSDB工具,我们对JVM的内存区域有了个大致的了解,接下来我们就来个深入一点的理解。我们都知道JVM叫做java虚拟机,什么是虚拟机呢,百度上有这么一句话:所谓的虚拟机(Virtual Machine)是指通过软件模拟的具有完整硬件系统功能的、运行在一个完全隔离环境中的完整计算机系统。既然JVM叫java虚拟机,那么JVM肯定和操作系统已于拥有一个完整的计算机系统。首先我们简单地看看一个操作系统是怎么执行一个任务的,我们都知道一台计算机有CPU、CPU缓存和内存(主内存),操作系统在执行一个任务时会先把数据存放到我们的内存当中,然后执行的时候会把这些数据从主存加载到CPU缓存再执行。所以我们的JVM应该也有对应的内存结构,而这个内存结构就是在我们的虚拟机栈的栈帧中。在看栈帧之前我们先来看虚拟机栈,虚拟机栈其实就是一个栈,它拥有栈先进后出的特点并且它存的就是需要执行的方法栈帧。举个简单的例子:

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

    private static void A() {
        B();
    }

    private static void B() {
        C();
    }

    private static void C() {
        
    }

在执行main方法时会先把main方法打包成一个栈帧压入到main线程的虚拟机栈当中然后执行,执行的过程中发现还要调用A方法,所以把A方法也打包成一个栈帧入栈,以此类推,终止会把c方法打包成栈帧入栈:
在这里插入图片描述
C方法运行完了,C方法出栈,接着B方法运行完了,B方法出栈、接着A方法运行完了,A方法出栈,最后main方法运行完了,main方法这个栈帧就出栈了。一个栈帧存储的就是这个方法运行时所需要的数据,所以接下来我们来看看一个栈帧会有哪些东西。
在这里插入图片描述

如上图所示,一个栈帧包含了局部变量表、操作数栈、动态链接和完成出口。局部变量表可以理解为操作系统的主存,而操作数栈则是CPU缓存,JVM的执行引擎就相当于CPU。为了详细描述虚拟机栈中每个内存的作用,这里再看一个稍微复杂一点的例子:

public class Person {
    public static void main(String[] args) {
        Person person = new Person();
        person.work();
    }

    public int work() {
        int x = 1, y = 2, z = (x + y) * 10;
        return z;
    }
}

JVM在执行这段代码前会把它编译为字节码,借助javap命令可以看到这段代码的字节码:

public class ch1.Person {
  public ch1.Person();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class ch1/Person
       3: dup
       4: invokespecial #3                  // Method "<init>":()V
       7: astore_1
       8: aload_1
       9: invokevirtual #4                  // Method work:()I
      12: pop
      13: return

  public int work();
    Code:
       0: iconst_1
       1: istore_1
       2: iconst_2
       3: istore_2
       4: iload_1
       5: iload_2
       6: iadd
       7: bipush        10
       9: imul
      10: istore_3
      11: iload_3
      12: ireturn
}

执行main方法前会把main方法的栈帧压入到虚拟机栈,然后new指令会创建Person的实例,调用Person的work方法时会把work方法打包成栈帧入栈,如下图所示:
在这里插入图片描述

把work栈帧入栈后,JVM的执行引擎(执行引擎有三种:字节码引擎、模板引擎和JIT)会执行
work方法的iconst_1,这个指令会把数据1加载到操作数栈中。这一步就类似于CPU加载操作数到CPU缓存,操作的结果如下:
在这里插入图片描述

接着,执行引擎会继续执行istore_1,把1的操作数存储到我们的局部变量表1的位置上,这一步类似于CPU把计算完的数据从CPU缓存存储到主存中,执行结果如下:
在这里插入图片描述

这样我们的操作数就存储到了我们的局部变量表里,然后类似的执行引擎执行iconst_2和istore_2指令会把操作数2从操作数栈存储到局部变量表,最终结果如下:
在这里插入图片描述

此时完成了对x、y的赋值操作,然后要执行他们的加法操作,所以要先把它们的值加载到操作数栈执行iload_1和iload_2指令:
在这里插入图片描述

把这两个操作数加载到操作数栈以后就可以做加法运算了,所以这里会执行iadd指令进行加法运算。1+2=3,所以执行完以后会得到一个3的操作数:
在这里插入图片描述

执行完了加法还得执行*10的操作,所以我们的操作数栈需要一个10的操作数,所以执行bipush指令把10加载进来:
在这里插入图片描述

接下来就可以执行操作指令imul,把操作数栈上的两个数相乘,得到一个30的数:
在这里插入图片描述

计算出结果以后还得把这个数存回我们的局部变量表,所以会执行istore_3指令,把结果30从操作数栈中存到局部变量表3的位置:
在这里插入图片描述
work方法执行完以后要把30给返回出去,所以要把30加载到操作数栈进行操作:
在这里插入图片描述
最后执行ireturn指令,把操作数栈中的数据返回即可。到这里work方法已经执行完了,不知道大家注意到了没有,除了虚拟机栈我还画了程序计数器。程序计数器是负责记录当前线程执行到哪里,就像操作数栈对应CPU缓存,局部变量表对应主存一样,程序计数器也对应这操作系统的程序计数器(又称为指令计数器),操作系统有一套操作指令,我们的JVM也有相对应的模拟指令。程序计数器也是唯一一块不会发生OOM的内存区域。
除了虚拟机栈还有一个本地方法栈,在jdk中有很多加了native关键字的方法,这些方法都不是java实现的,而是C或者C++实现的,这些方法被称为本地方法。而运行本地方法的内存就是本地方法栈,这是java虚拟机规范里规定的。除此以外还有方法区,方法区是各线程共享的一个内存区域,它存储的是每一个类的结构信息,例如运行时常量池字段和方法数据、构造函数和普通方法的字节码内容,还包括了一些类、实例、接口初始化时用到的特殊方法。方法区只是JVM规范对内存的“逻辑划分”,在JDK1.7以前,Hotspot虚拟机中使用永久代来实现方法区,而到了JDK1.8以后则使用元空间来实现方法区。JDK1.8之所以使用元空间来替换永久代的实现,可能是基于以下几点的考虑:

  1. JRockit VM是没有永久代的,而HotSpot JVM要融合JRockit VM
  2. 永久代的使用过程中容易发生内存不够用导致内存溢出
  3. 永久代的大少不好确定,永久代的大小依赖于calss数量、常量池的大小和方法的大小等等
  4. 永久代垃圾回收的效率低下
    所以JDK1.8把永久的静态变量和运行时常量池移到了堆,其他的部分则存储到非堆内存中,这样就把永久代的空间给去掉了,并且使用元空间代替,由于除了静态变量吃和运行时常量池是存储在堆中其他的部分都存储在非堆中(堆外内存),所以元空间是存储在堆外内存(本地内存)。堆外内存也叫直接内存,它不是JVM运行时数据区的一部分也不是java虚拟机规范中定义的内存区域。这块区域可以使用directByteBuffer 对象直接引用并操作,如果使用了NIO的话,这块内存会被频繁使用。值得注意的是当这块内存使用完了以后需要手动回收,不然会出现内存泄漏。

内存溢出

1. 栈溢出
官方文档:
https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html#BABHDABI
在这里插入图片描述
通过查看官方文档可以看到,设置栈大小的参数是:-Xss1m,它的默认大小在不同的操作系统上是不一样的。而我们常用的操作系统如:Linux/x64上就是1M。栈的内存溢出是比较难出现的,我们都知道运行一个方法时会把方法打包成一个栈帧压入虚拟机栈中,然而这个栈帧是比较小的,如果想要栈发生内存溢出则需要大量的栈帧压入到虚拟机栈。这就意味着要有大量的方法被不断调用,可以想到的一个场景就是无限递归。无限递归调用的方法无法出栈,而被调到的方法会不断入栈,这样迟早会把虚拟机栈撑爆。

// An highlighted block
public class OOMStackTest {
    public static void main(String[] args) {
        a();
    }

    private static void a() {
        a();
    }
}

在这里插入图片描述

2.堆溢出
堆作为JVM最大的一块内存也是会发生内存溢出的,如果堆发生了内存溢出可能会是由内存泄漏、堆空间分配不够合理、对象的生命周期过长、持有时间状态过长或者存储结构不合理等情况导致的。
1)内存泄漏导致的内存溢出

public static void main(String[] args) {
        HashMap map = new HashMap();
        int i = 0;
        for (; ; ) {
            A a = new A();
            map.put(a, "a" + i++);

            //重新计算了hashCode
            a.restHashCode();
            //hashCode不同导致删除失败从而导致内存泄漏
            map.remove(a);//使用完后删除

            System.out.println(map);
        }
    }

    public static class A {
        byte aByte[];

        int hashCode = 0;

        A() {
            //创建一个1M的byte数组
            aByte = new byte[1024 * 1024];
            Random random = new Random();
            //给hashCode赋值
            hashCode = random.nextInt(2048);
        }

        /**
         * 重写了hashCode方法
         *
         * @return
         */
        @Override
        public int hashCode() {
            return hashCode;
        }

        /**
         * 重新计算hashCode
         */
        public void restHashCode() {
            Random random = new Random();
            hashCode = random.nextInt(2048);
        }
    }

在这里插入图片描述
在使用HashMap并且用自己设计的类作为key时,此时的hashCode设计的不合理导致了HashMap元素删除失败从而导致了内存泄漏,此时只要运行一段时候就会出现OOM的异常。
2)对象生命周期过长

public class TooLong {
    public static void main(String[] args) {
        List<A> list = new ArrayList<>();
        for (;;){
            list.add(new A());
        }
    }


    public static class A {
        byte aByte[];

        A() {
            aByte = new byte[1024 * 1024];
        }
    }
}

由于list里的元素一直没有释放而又不停地往list添加元素,最终导致的OOM。所以使用缓存时需要注意定时清理或者使用弱引用。
在这里插入图片描述

3. 方法区溢出
1)方法区溢出分为运行时常量池溢出
2)方法区中Class对象占用超过了我们的配置导致的溢出
回收class对象的必要非充分条件:1、该类的所有实例都已经被回收了,2、该类的classloader已经被回收了,3、该类的java.lang.Class对象没有被任何地方引用,不能通过反射访问该类的方法。
之所以是非充分条件是因为除此以外还能通过JVM参数:-Xnoclassgc禁用JVM对类的垃圾回收。
4. 直接内存溢出
直接内存可以使用MaxDirectMemorySize来设置它的大小,默认是与堆内存一样大。(堆内存指的是去掉一个Survivor的大小,因为新生代的采用的是复制算法,所以会有一个Survivor大小的空间被浪费掉,所以不算堆的大小)
直接内存溢出比较难排查,如果在HeapDump 文件中不会看见有什么明显的异常情况,而又发生了 OOM,同时 Dump 文件很小,那么可以考虑重点排查下直接内存方面的原因。

常量池

class常量池
在class文件中,除了有版本、字段、方法和接口等描述信息外,还有一项信息是常量池。常量池用于存放编译期间生成的各种字面量符号引用
字面量: 给基本类型变量赋值的方式就叫字面量或者字面值。
比如:String a = “b”,这里的"b"就是字符串字面量,以此类推,还有整数字面值、浮点数字面量、字符字面量。
符号引用: 符号引用以一组符号来描述所引用的目标。符号引用可以是任何形式的字面量,Java编译的时候会把每一个Java类编译成class文件,但是在编译的时候虚拟机并不知道所引用类的地址(实际地址),就用符号代替。而在类的解析阶段,JVM才会把这个符号引用转换为真正的地址。
比如:有一个java类Peopel,而这个People类又引用了Tool类,在编译People类的时候并不知道引用类Tool的实际内存地址,因此只能用符合引用(org.simple.Tool)来代替。而在类加载器加载People的时候,可以通过JVM获取到Tool的真实内存地址,因此就可以将符合引用org.simple.Tool替换为Tool类的实际内存地址。
运行时常量池(Constant_Pool)
运行时常量池(Runtime Constant Pool)是每一个类或接口的常量池的运行时的表现形式,它包括了若干种不同的常量;从编译期可知的数值字面量到必须运行期解析后才能获得的方法或字段引用。(这是JVM规范中的描述)
在类加载完成以后,会将class常量池中的符号引用值转存到运行时常量池中,在类解析之后,将符号引用替换为直接引用。虽然在JDK1.7以后,就转移到了堆上,但这里指的是物理空间,而逻辑上还是属于方法区(方法区是逻辑分区)。
在JDK1.8中,使用元空间替换了永久代,但方法区没有改变。虚拟机规范没有规定方法区要怎么实现,只是规定了必须要有这块区域,而这块区域是逻辑上的区域,至于把方法区划分到那个物理区域不同的JVM有不同的实现。
字符串常量池
字符串常量池在很多JVM规范中都没有官方定义,所以这块区域备受争议。所以这里也不去研究它和运行时常量池的关系,只从JVM的设计角度来分析它的作用。
以jdk1.8为例,字符串常量池是存放在堆中的,并且和java.lang.String类有很大关系。设计这块内存区域的主要原因是:String作为java语言中重要的数据类型,是内存中占据空间最大的一个对象。高效地使用字符串,可以提升系统的整体性能。所以字符串常量池的重点就在于String类。
String类代码片段:
在这里插入图片描述
String类是对char数组的封装,主要有两个成员变量:value (char数组) 和 hash值。从这个代码片段中可以看到char数组和String类都被final关键字修饰,所以String类不可继承,而char[]则被final+private修饰,代表了String对象不可更改。所以String对象一旦创建,就不能对它进行改变。
java这样设计的好处在于:

  1. 保证String对象的安全性。如果String可以改变那么String对象将被恶意修改。
  2. 保证hash值不会频繁变更,确保了唯一性,使得类似于HashMap容器可以使用相应的key-value缓存功能。
  3. 可以实现字符串常量池。在java中通常有两种创建字符串对象的方式,一种是通过字符串常量的方式创建,如:String str = “abc”;另一种是字符串变量通过new形式创建,如:String str = new String(“abc”)。

String 的创建方式及内存分配的方式
1、String str = “abc”;
当代码使用这种方式创建字符串对象时,JVM首先会检查该对象是否在字符串常量池中,如果在,则返回该对象的引用,否则新的字符串将在常量池中被创建。这种方式可以减少同一个值的字符串对象的重复创建,节约内存。
2、String str = new String(“abc”)
首先,在编译类文件时,"abc"常量字符串会被放入到常量结构中,在类加载时,"abc"将会在常量池中被创建;其次,在调用new的时候,字符串会调用String的构造函数,同时引用常量池中的"abc"字符串,在堆内创建一个String的对象;最后str将引用这个String对象。
3、Location location = new Location(); location.setCity(“深圳”);
使用new,对象会创建在堆中,同时赋值的话,会在常量池中创建一个字符串对象,复制到堆中。具体的复制过程是先将常量池中的字符串压入到栈中,在使用String的构造方法时,会拿到栈中的字符串作为构造方法的参数。这个构造函数是一个char数组的赋值过程,而不是new出来的,所以是引用了常量池中的字符串对象。存在引用关系。
4、String str = “ab” + “cd” + “ef”;
字符串的拼接在开发过程中非常常见,然而字符串对象却是不可变的。所以分析代码可知,这个过程会先生成ab对象,再生成abcd对象,最后才生成abcdef对象,从理论上来说这段代码是低效的。
所以编译器会帮我们进行优化,如果查看编译后的代码你会发现这种代码会被优化成:String str= “abcdef”;
5、在大循环上使用+拼接字符串

  String str = "abcd";
  for(int i = 0;i < 1000; i++){
	str += i;
  }
  //java 编译器会对这段代码进行优化,java在拼接字符串时会偏向使用StringBuilder,这样可以提高程序的效率
  //String str = "abcd";
  //for(int i= 0;i < 1000; i++){
  //  str = (new StringBuilder(String.valueOf(str))).append(i).toString();
  //}

intern
String 的 intern 方法,如果常量池中有相同的值,就会重复使用该对象,返回对象的引用。

  String a = new String("str").intern();
  String b = new String("str").intern();
  if(a == b){
	System.out.println("a == b");
  }else{
 	System.out.println("a != b");
  }

1、new String() 会在堆内存中创建一个a的 String 对象,str会在常量池中创建
2、在调用 intern 方法后,会去常量池检查是否有该字符串的引用,有就返回引用。
3、调用new String 会在堆内存创建一个b的 String 对象,。
4、在调用 intern 方法后,会去常量池检查是否有该字符串的引用,有就返回引用。
所以a和b引用的是同一个对象。

JVM中的对象及引用

在这里插入图片描述

1)检查加载
首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用(以一组符号来描述所引用的目标),并且检查类是否已经被加载、解析和初始化。
2)内存分配
接下来虚拟机将为新生对象分配内存。为对象分配空间的任务等同于把一块确认大小的内存从堆中划分出来。内存分配一般有两种方式:指针碰撞空闲列表

指针碰撞
如果java堆内存是绝对规整的,所有用过的内存都放在一边,空闲的放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针指向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”。
空闲列表
如果java堆内存不是规整的,已使用内存和空闲内存相互交错,那就没有办法简单地使用指针碰撞,虚拟机就必须维护上一个空闲列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”。

选择哪种分配方式是由java堆是否规整决定的,而java堆是否规整又是由所采用的垃圾回收器是否带压缩整理功能决定。例如 Serial、ParNew 等带有压缩的整理的垃圾回收器的话,系统采用的是指针碰撞,既简单又高效。而使用 CMS 这种不带压缩(整理)的垃圾回收器的话,理论上只能采用较复杂的空闲列表。
并发安全
除如何划分可用空间之外,还有另外一个需要考虑的问题是对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情 况下也并不是线程安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。有两种方案可以保证内存分配的并发安全,一种是采用CAS机制;另一种是分配缓冲。
CAS 机制
虚拟机采用CAS配上失败重试的方式保证更新操作的原子性。
在这里插入图片描述
分配缓存
这种分配方式是给每个线程预先分配一小块私有内存,也就是本地线程分配缓存(Thread Local Allocation Buffer,TLAB),JVM在线程初始化的同时会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个buffer,如果需要分配内存,就在自己的buffer上分配,这样就不存在竞争的情况,大大提升分配效率,当buffer容量不够的时候,再重新从Eden区域申请一块继续使用。
TLAB的目的就是在为新对象分配内存空间时,让每个java应用线程能在使用自己专属的分配指针来分配空间,减少同步开销。
TLAB 只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。当一个 TLAB 用满(分 配指针 top 撞上分配极限 end 了),就新申请一个 TLAB。
参数:
-XX:+UseTLAB 允许在年轻代空间中使用线程本地分配块(TLAB)。默认情况下启用此选项。要禁用 TLAB,请指定-XX:-UseTLAB。
https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
3)内存空间初始化
内存分配完成后(注意不是构造方法),虚拟机需要将分配到的内存空间都初始化为零值(如 int 值为 0,boolean 值为 false 等等)。这一步操作保证了对象 的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
4)设置
接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息(Java classes 在 Java hotspot VM 内部表示为类 元数据)、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象的对象头之中。
5)对象初始化
在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚刚开始,所有的字段都还为零值。 所以,一般来说,执行 new 指令之后会接着把对象按照程序员的意愿进行初始化(构造方法),这样一个真正可用的对象才算完全产生出来。

对象的内存布局

在这里插入图片描述
在 HotSpot 虚拟机中,对象在内存中存储的布局可以分为 3 块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。 对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。 对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。 如果对象是一个 java 数组,那么在对象头中还有一块用于记录数组长度的数据。 第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于 HotSpot VM 的自动内存管理系统要求对对象的大小必须 是 8 字节的整数倍。当对象其他数据部分没有对齐时,就需要通过对齐填充来补全。(具体的对象数据可以参考https://blog.csdn.net/JankeDeng/article/details/108747516)
对象访问定位
建立对象是为了使用对象,我们的 Java 程序需要通过栈上的 reference 数据来操作堆上的具体对象。目前主流的访问方式有使用句柄和直接指针两种。
句柄
如果使用句柄访问的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类 型数据各自的具体地址信息。 使用句柄来访问的最大好处就是 reference 中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实 例数据指针,而 reference 本身不需要修改。
直接指针
如果使用直接指针访问, reference 中存储的直接就是对象地址。 这两种对象访问方式各有优势,使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在 Java 中非常频 繁,因此这类开销积少成多后也是一项非常可观的执行成本。 对 Sun HotSpot 而言,它是使用直接指针访问方式进行对象访问的。
判断对象的存活
在堆里面存放着几乎所有的对象实例,垃圾回收器在对对进行回收前,要做的事情就是确定这些对象中哪些还是“存活”着,哪些已经“死去”(死去 代表着不可能再被任何途径使用得对象了)
什么是垃圾?
C 语言申请内存:malloc free
C++: new delete
C/C++ 手动回收内存
Java: new
Java 是自动内存回收,编程上简单,系统不容易出错。
手动释放内存,容易出两种类型的问题:
1、忘记回收
2、多次回收
没有任何引用指向的一个对象或者多个对象(循环引用)
引用计数法
在对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1,当引用失效时,计数器减 1。
Python 在用,但主流虚拟机没有使用,因为存在对象相互引用的情况,这个时候需要引入额外的机制来处理,这样做影响效率。
可达性分析
来判定对象是否存活的。这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为 引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的。 作为 GC Roots 的对象包括下面几种(重点是前面 4 种):

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象;各个线程调用方法堆栈中使用到的参数、局部变量、临时变量等。
  2. 方法区中类静态属性引用的对象;java 类的引用类型静态变量。
  3. 方法区中常量引用的对象;比如:字符串常量池里的引用。
  4. 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象。
  5. JVM 的内部引用(class 对象、异常对象 NullPointException、OutofMemoryError,系统类加载器)。
  6. 所有被同步锁(synchronized 关键)持有的对象。
  7. JVM 内部的 JMXBean、JVMTI 中注册的回调、本地代码缓存等
  8. JVM 实现中的“临时性”对象,跨代引用的对象(在使用分代模型回收只回收部分代的对象,这个后续会细讲,先大致了解概念)

以上的回收都是对象,类的回收条件: 注意 Class 要被回收,条件比较苛刻,必须同时满足以下的条件(仅仅是可以,不代表必然,因为还有一些参数可以进行控制):

  1. 该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。
  2. 加载该类的 ClassLoader 已经被回收。
  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
  4. 参数控制: 废弃的常量和静态变量的回收其实就和 Class 回收的条件差不多。
    Finalize 方法
    即使通过可达性分析判断不可达的对象,也不是“非死不可”,它还会处于“缓刑”阶段,真正要宣告一个对象死亡,需要经过两次标记过程,一次是 没有找到与 GCRoots 的引用链,它将被第一次标记。随后进行一次筛选(如果对象覆盖了 finalize),我们可以在 finalize 中去拯救。
    这里建议大家尽量不要使用 finalize,因为这个方法太不可靠。在生产中你很难控制方法的执行或者对象的调用顺序,建议大家忘了 finalize 方法!因为在 finalize 方法能做的工作,java 中有更好的,比如 try-finally 或者其他方式可以做得更好。
    各种引用
    强引用
    一般的 Object obj = new Object() ,就属于强引用。在任何情况下,只有有强引用关联(与根可达)还在,垃圾回收器就永远不会回收掉被引用的对象。
    软引用 SoftReference
    一些有用但是并非必需,用软引用关联的对象,系统将要发生内存溢出(OuyOfMemory)之前,这些对象就会被回收(如果这次回收后还是没有足够的空间,才会抛出内存溢出)。参见代码: VM 参数 -Xms10m -Xmx10m -XX:+PrintGC
    弱引用 WeakReference
    一些有用(程度比软引用更低)但是并非必需,用弱引用关联的对象,只能生存到下一次垃圾回收之前,GC 发生时,不管内存够不够,都会被回收。
    **注意:**软引用 SoftReference 和弱引用 WeakReference,可以用在内存资源紧张的情况下以及创建不是很重要的数据缓存。当系统内存不足的时候,缓存 中的内容是可以被释放的。 实际运用(WeakHashMap、ThreadLocal)
    虚引用 PhantomReference
    幽灵引用,最弱(随时会被回收掉) 垃圾回收的时候收到一个通知,就是为了监控垃圾回收器是否正常工作。
    对象的分配策略
    栈上分配(没有逃逸)
    即方法中的对象没有发生逃逸。 逃逸分析的原理:分析对象动态作用域,当一个对象在方法中定义后,它可能被外部方法所引用。 比如:调用参数传递到其他方法中,这种称之为方法逃逸。甚至还有可能被外部线程访问到,例如:赋值给其他线程中访问的变量,这个称之为线程逃逸。 从不逃逸到方法逃逸到线程逃逸,称之为对象由低到高的不同逃逸程度。 如果确定一个对象不会逃逸出线程之外,那么让对象在栈上分配内存可以提高 JVM 的效率。
    代码分析
public class EscapeAnalysisTest {
    public static void main(String[] args) throws InterruptedException {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 50000000; i++) {
            allocate();
        }
        System.out.println((System.currentTimeMillis() - start) + "ms");
        Thread.sleep(600000);
    }

    static void allocate() {
        MyObject myObject = new MyObject(2021, 2021.3);
    }

    static class MyObject {
        int a;
        double b;

        MyObject(int a, double b) {
            this.a = a;
            this.b = b;
        }
    }
}

在这里插入图片描述
JVM默认情况下是开启了逃逸分析,所以myobject对象没有逃逸,所以会分配到栈中。现在使用-XX:-DoEscapeAnalysis参数关闭讨逆分析,对比结果:
在这里插入图片描述
关闭讨逆分析后的结果很明显,说明逃逸分析对代码的执行性能有很大的影响,那为什么会有这个影响呢?
逃逸分析
如果是逃逸分析出来的对象可以在栈上分配的话,那么该对象的生命周期就跟随线程了,就不需要垃圾回收,如果是频繁的调用此方法则可以得到很大的性能提高。 采用了逃逸分析后,满足逃逸的对象在栈上分配。没有开启逃逸分析,对象都在堆上分配,会频繁触发垃圾回收(垃圾回收会影响系统性能),导致代码运行慢。
下面通过查看垃圾回收的日志来证实这点,开启打印GC日志的参数:-XX:+PrintGC。
开启逃逸分析:
在这里插入图片描述
关闭逃逸分析:
在这里插入图片描述
可以看到关闭了逃逸分析,JVM 在频繁的进行垃圾回收(GC),正是这一块的操作导致性能有较大的差别。

对象优先在 Eden 区分配
大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间分配时,虚拟机将发起一次 Minor GC。
大对象直接进入老年代
大对象就是指需要大量连续内存空间的 Java 对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组。 大对象对虚拟机的内存分配来说就是一个不折不扣的坏消息,比遇到一个大对象更加坏的消息就是遇到- -群“朝生夕灭”的“短命大对象”,我们写程序 的时候应注意避免。 在 Java 虚拟机中要避免大对象的原因是,在分配空间时,它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好 它们。 而当复制对象时,大对象就意味着高额的内存复制开销。 HotSpot 虚拟机提供了-XX:PretenureSizeThreshold 参数,指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在 Eden 区及两个 Survivor 区之间来回复制,产生大量的内存复制操作。 这样做的目的:
1.避免新生代大量的内存复制
2.避免大对象导致的提前进行垃圾回收(老年代还有空间)。
PretenureSizeThreshold 参数只对 Serial 和 ParNew 两款收集器有效。
-XX:PretenureSizeThreshold=4m
长期存活对象进入老年区
HotSpot 虚拟机中多数收集器都采用了分代收集来管理堆内存,那内存回收时就必须能决策哪些存活对象应当放在新生代,哪些存活对象放在老年代中。 为做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。(参考https://blog.csdn.net/JankeDeng/article/details/108747516)
如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1,对象在 Survivor 区中每熬过一次 Minor GC,年龄就增加 1,当它的年龄增加到一定程度(并发的垃圾回收器默认为 15),CMS 是 6 时,就会被晋升到老年代中。 -XX:MaxTenuringThreshold 调整。
对象年龄动态判定
为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 空间中 相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的 年龄。
空间分配担保
在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么 Minor GC 可以确保是安全 的。如果不成立,则虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历 次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC,尽管这次 Minor GC 是有风险的,如果担保失败则会进行一次 Full GC;如果小 于,或者 HandlePromotionFailure 设置不允许冒险,那这时也要改为进行一次 Full GC。

垃圾回收机制及算法

什么是 GC
Java 与 C++等语言最大的技术区别:自动化的垃圾回收机制(GC)
为什么要了解 GC 和内存分配策略
1、面试需要
2、GC 对应用的性能是有影响的;
3、写代码有好处
:栈中的生命周期是跟随线程,所以一般不需要关注
:堆中的对象是垃圾回收的重点
方法区/元空间:这一块也会发生垃圾回收,不过这块的效率比较低,一般不是我们关注的重点
分代回收理论
当前商业虚拟机的垃圾回收器,大多遵循“分代收集”的理论来进行设计,这个理论大体上是这么描述的:
1、 绝大部分的对象都是朝生夕死。
2、 熬过多次垃圾回收的对象就越难回收。
根据以上两个理论,朝生夕死的对象放一个区域,难回收的对象放另外一个区域,这个就构成了新生代(eden、from、to)和老年代。
GC 分类
市面上发生垃圾回收的叫法很多,我大体整理了一下:
1、 新生代回收(Minor GC/Young GC):指只是进行新生代的回收。
2、 老年代回收(Major GC/Old GC):指只是进行老年代的回收。目前只有 CMS 垃圾回收器会有这个单独的回收老年代的行为。 (Major GC 定义是比较混乱,有说指是老年代,有的说是做整个堆的收集,这个需要你根据别人的场景来定,没有固定的说法)
3、 整堆回收(Full GC):收集整个 Java 堆和方法区(注意包含方法区)
垃圾回收算法
垃圾回收算法的实现设计到大量的程序细节,并且每一个平台的虚拟机操作内存的方式都有不同,所以不需要去了解算法的实现,我们重点讲解 3 种算 法的思想。
复制算法(Copying)
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使 用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要按顺序分配内存即可, 实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半。 但是要注意:内存移动是必须实打实的移动(复制),所以对应的引用(直接指针)需要调整。 复制回收算法适合于新生代,因为大部分对象朝生夕死,那么复制过去的对象比较少,效率自然就高,另外一半的一次性清理是很快的。
Appel 式回收
一种更加优化的复制回收分代策略:具体做法是分配一块较大的 Eden 区和两块较小的 Survivor 空间(你可以叫做 From 或者 To,也可以叫做 Survivor1 和 Survivor2)
专门研究表明,新生代中的对象 98%是“朝生夕死”的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较 小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor[1]。当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上, 最后清理掉 Eden 和刚才用过的 Survivor 空间。
HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%(80%+10%),只有 10%的内存会被 “浪费”。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于 10%的对象存活,当 Survivor 空间不够用时,需要 依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)
标记-清除算法(Mark-Sweep)
算法分为“标记”和“清除”两个阶段:首先扫描所有对象标记出需要回收的对象,在标记完成后扫描回收所有被标记的对象,所以需要扫描两遍。 回收效率略低,如果大部分对象是朝生夕死,那么回收效率降低,因为需要大量标记对象和回收对象,对比复制回收效率要低。 它的主要问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连 续内存而不得不提前触发另一次垃圾回收动作。
回收的时候如果需要回收的对象越多,需要做的标记和清除的工作越多,所以标记清除算法适用于老年代。
标记-整理算法(Mark-Compact)
首先标记出所有需要回收的对象,在标记完成后,后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端 边界以外的内存。标记整理算法虽然没有内存碎片,但是效率偏低。
我们看到标记整理与标记清除算法的区别主要在于对象的移动。对象移动不单单会加重系统负担,同时需要全程暂停用户线程才能进行,同时所有引用 对象的地方都需要更新(直接指针需要调整)。
所以看到,老年代采用的标记整理算法与标记清除算法,各有优点,各有缺点。

JVM 中常见的垃圾回收器
在新生代中,每次垃圾回收时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成回收。 而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。
Serial/Serial Old
JVM 刚诞生就只有这种,最古老的,单线程,独占式,成熟,适合单 CPU,一般用在客户端模式下。 这种垃圾回收器只适合几十兆到一两百兆的堆空间进行垃圾回收(可以控制停顿时间再 100ms 左右),但是对于超过这个大小的内存回收速度很慢,所 以对于现在来说这个垃圾回收器已经是一个鸡肋。
参数设置:-XX:+UseSerialGC 新生代和老年代都用串行收集器
Stop The World(STW)
单线程进行垃圾回收时,必须暂停所有的工作线程,直到它回收结束。这个暂停称之为“Stop The World”,但是这种 STW 带来了恶劣的用户体验,例如:应 用每运行一个小时就需要暂停响应 5 分。这个也是早期 JVM 和 java 被 C/C++语言诟病性能差的一个重要原因。所以 JVM 开发团队一直努力消除或降低 STW 的时间。
Parallel Scavenge(ParallerGC)/Parallel Old
为了提高回收效率,从 JDK1.3 开始,JVM 使用了多线程的垃圾回收机制,关注吞吐量的垃圾收集器,高吞吐量则可以高效率地利用 CPU 时间,尽快完成 程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
所谓吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),虚拟机总 共运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%。
该垃圾回收器适合回收堆空间上百兆~几个 G。
参数设置:
-XX:+UseParallelGC 新生代使用 Parallel Scavenge,老年代使用 Parallel Old(jdk1.8默认组合)
-XX:MaxGCPauseMillis 设置GC暂停时间的目标(以毫秒为单位)。这是一个目标,并且JVM将尽最大的努力来实现它。默认情况下没有最大暂停时间值。
不过大家不要以为把这个值设置得更小一点就能使得系统的垃圾回收速度变得更快,垃圾收集停顿时间缩短是以牺牲吞吐 量和新生代空间为代价换取的:系统把新生代调得小一些,收集 300MB 新生代肯定比收集 500MB 快,但这也直接导致垃圾收集发生得更频繁,原来 10 秒 收集一次、每次停顿 100 毫秒,现在变成 5 秒收集一次、 每次停顿 70 毫秒。停顿时间的确在下降,但吞吐量也降下来了。
-XX:GCTimeRatio
-XX:GCTimeRatio 参数的值则应当是一个大于 0 小于 100 的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。 例如:把此参数设置为 19, 那允许的最大垃圾收集时占用总时间的 5% (即 1/(1+19)), 默认值为 99,即允许最大 1% (即 1/(1+99))的垃圾收集时间 由于与吞吐量关系密切,ParallelScavenge 是“吞吐量优先垃圾回收器”。
-XX:+UseAdaptiveSizePolicy
-XX:+UseAdaptiveSizePolicy (默认开启)。这是一个开关参数, 当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn)、Eden 与 Survivor 区的 比例(-XX:SurvivorRatio)、 晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调 整这些参数以提供最合适的停顿时间或者最大的吞吐量。
ParNew
多线程垃圾回收器,与 CMS 进行配合,对于 CMS(CMS 只回收老年代),新生代垃圾回收器只有 Serial 与 ParNew 可以选。和 Serial 基本没区别,唯一的区 别:多线程,多 CPU 的,停顿时间比 Serial 少。(在 JDK9 以后,把 ParNew 合并到了 CMS 了) 大致了解下搭配关系即可,后续版本已经接近淘汰。
Concurrent Mark Sweep (CMS)
收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的 Java 应用集中在互联网站或者 B/S 系统的服务端上,这类应用尤其重视服务 的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS 收集器就非常符合这类应用的需求。
从名字(包含“Mark Sweep”)上就可以看出,CMS 收集器是基于“标记—清除”算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些, 整个过程分为 4 个步骤,包括:

  1. 初始标记-短暂,仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快。
  2. 并发标记-和用户的应用程序同时进行,进行 GC Roots 追踪的过程,标记从 GCRoots 开始关联的所有对象开始遍历整个可达分析路径的对象。这个时
    间比较长,所以采用并发处理(垃圾回收器线程和用户线程同时工作)
  3. 重新标记-短暂,为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标 记阶段稍长一些,但远比并发标记的时间短。
  4. 并发清除
    由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS 收集器的内存回收过程是与用 户线程一起并发执行的。 -XX:+UseConcMarkSweepGC ,表示新生代使用 ParNew,老年代的用 CMS
    CMS特点
    CPU 敏感:CMS 对处理器资源敏感,毕竟采用了并发的收集、当处理核心数不足 4 个时,CMS 对用户的影响较大。
    浮动垃圾:由于 CMS 并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS 无法 在当次收集中处理掉它们,只好留待下一次 GC 时再清理掉。这一部分垃圾就称为“浮动垃圾”。
    由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。
    在 1.6 的版本中老年代空间使用率阈值(92%)
    如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。
    会产生空间碎片:标记 - 清除算法会导致产生不连续的空间碎片
    总体来说,CMS 是 JVM 推出了第一款并发垃圾收集器,所以还是非常有代表性。
    但是最大的问题是 CMS 采用了标记清除算法,所以会有内存碎片,当碎片较多时,给大对象的分配带来很大的麻烦,为了解决这个问题,CMS 提供一个 参数:-XX:+UseCMSCompactAtFullCollection,一般是开启的,如果分配不了大对象,就进行内存碎片的整理过程。 这个地方一般会使用 Serial Old ,因为 Serial Old 是一个单线程,所以如果内存空间很大、且对象较多时,CMS 发生这样情况会很卡。
    CMS 总结
    CMS 问题比较多,所以现在没有一个版本默认是 CMS,只能手工指定。但是它毕竟是第一个并发垃圾回收器,对于了解并发垃圾回收具有一定意义,所 以我们必须了解。 为什么 CMS 采用标记-清除,在实现并发的垃圾回收时,如果采用标记整理算法,那么还涉及到对象的移动(对象的移动必定涉及到引用的变化,这个需 要暂停业务线程来处理栈信息,这样使得并发收集的暂停时间更长),所以使用简单的标记-清除算法才可以降低 CMS 的 STW 的时间。
    该垃圾回收器适合回收堆空间几个 G~ 20G 左右。

Garbage First(G1)
设计思想
随着 JVM 中内存的增大,STW 的时间成为 JVM 急迫解决的问题,但是如果按照传统的分代模型,总跳不出 STW 时间不可预测这点。 为了实现 STW 的时间可预测,首先要有一个思想上的改变。G1 将堆内存“化整为零”,将堆内存划分成多个大小相等独立区域(Region),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间,或者老年代空间。回收器能够对扮演不同角色的 Region 采用不同的策略去处理,这样无论是 新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。
Region
Region 可能是 Eden,也有可能是 Survivor,也有可能是 Old,另外 Region 中还有一类特殊的 Humongous 区域,专门用来存储大对象。 G1 认为只要大小超过 了一个 Region 容量一半的对象即可判定为大对象。每个 Region 的大小可以通过参数-XX:G1HeapRegionSize 设定,取值范围为 1MB~32MB,且应为 2 的 N 次 幂。而对于那些超过了整个 Region 容量的超级大对象,将会被存放在 N 个连续的 Humongous Region 之中,G1 的进行回收大多数情况下都把 Humongous Region 作为老年代的一部分来进行看待。
参数设置
开启参数
-XX:+UseG1GC
分区大小
-XX:+G1HeapRegionSize
设置使用G1垃圾回收器时,将java堆细分为区域的大小。取值范围是1MB到32MB。默认区域大小是根据堆大小和人体工程学确定的。
一般建议逐渐增大该值,随着 size 增加,垃圾的存活时间更长,GC 间隔更长,但每次 GC 的时间也会更长。
最大 GC 暂停时间
-XX:MaxGCPauseMillis
设置GC暂停时间的目标(以毫秒为单位)。这是一个软目标,并且JVM将尽最大努力实现它。默认情况下,没有最大暂停时间值。

运行过程
G1 的运作过程大致可划分为以下四个步骤:

  1. 初始标记( Initial Marking)
    仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。 这个阶段需要停顿线程,但耗时很短,而且是借用进行 Minor GC 的时候同步完成的,所以 G1 收集器在这个阶段实际并没有额外的停顿。
    TAMS 是什么?
    要达到 GC 与用户线程并发运行,必须要解决回收过程中新对象的分配,所以 G1 为每一个 Region 区域设计了两个名为 TAMS(Top at Mark Start)的指针, 从 Region 区域划出一部分空间用于记录并发回收过程中的新对象。这样的对象认为它们是存活的,不纳入垃圾回收范围。
  2. 并发标记( Concurrent Marking)
    从 GC Root 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫 描 完 成 以 后 , 并 发 时 有 引 用 变 动 的 对 象 , 这 些 对 象 会 漏 标 ( 后 续 再 讲 三 色 标 记 的 时 候 会 细 讲 这 个 问 题 ) , 漏 标 的 对 象 会 被 一 个 叫 做 SATB(snapshot-at-the-beginning)算法来解决
  3. 最终标记( Final Marking)
    对用户线程做另一个短暂的暂停,用于处理并发阶段结后仍遗留下来的最后那少量的 SATB 记录(漏标对象)。
  4. 筛选回收( Live Data Counting and Evacuation)
    负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构 成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动, 是必须暂停用户线程,由多条收集器线程并行完成的。

特点
并行与并发: G1 能充分利用多 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿的时间,部分其他收集器 原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 Java 程序继续执行。
分代收集: 与其他收集器一样,分代概念在 G1 中依然得以保留。虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但它能够采用不同的方式 去处理新创建的对象和已经存活了一段时间、熬过多次 GC 的旧对象以获取更好的收集效果。
空间整合: 与 CMS 的“标记—清理”算法不同,G1 从整体来看是基于“标记—整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复 制”算法实现的,但无论如何,这两种算法都意味着 G1 运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运 行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC。
追求停顿时间: -XX:MaxGCPauseMillis 指定目标的最大停顿时间,G1 尝试调整新生代和老年代的比例,堆大小,晋升年龄来达到这个目标时间。

该垃圾回收器适合回收堆空间上百 G。一般在 G1 和 CMS 中间选择的话平衡点在 6~8G,只有内存比较大 G1 才能发挥优势。

垃圾回收器整理

收集器收集对象和算法收集器类型说明适用场景
Serial新生代,复制算法单线程简单高效; 适合内存不大的情况;
ParNew新生代,复制算法并行的多线程收集 器ParNew 垃 圾 收 集 器 是 Serial 收集器的多线程版 本搭配CMS垃圾回收器的首选
Parallel Scavenge 吞吐量优先收集器新生代,复制算法并行的多线程收集 器类似 ParNew,更加关注吞 吐量,达到一个可控制的 吞吐量;本身是 Server 级别多 CPU 机 器上的默认 GC 方式,主要 适合后台运算不需要太多交 互的任务;
Serial Old老年代,标记整理算法单线程Client 模式下虚拟机使用
Parallel Old老年代,标记整理算法并行的多线程收集器Parallel Scavenge 收集器的老年代版 本,为了配合 Parallel Scavenge 的面 向吞吐量的特性而开发的对应组合;在注重吞吐量以及 CPU 资 源敏感的场合采用
CMS老年代,标记清除算法并行与并发收集器尽可能的缩短垃圾收集时用户线程 停止时间;缺点在于: 1.内存碎片 2.需要更多 cpu 资源 3.浮动垃圾问题,需要更大的堆空间重视服务的响应速度、系统 停顿时间和用户体验的互 联网网站或者 B/S 系统。互 联网后端目前 cms 是主流 的垃圾回收器;
G1跨新生代和老年代;标记 整理 + 化整为零并行与并发收集器JDK1.7 才正式引入,采用分区回收的 思维,基本不牺牲吞吐量的前提下完 成低停顿的内存回收;可预测的停顿 是其最大的优势;面向服务端应用的垃圾回 收器,目标为取代 CMS

并行:垃圾收集的多线程的同时进行。 并发:垃圾收集的多线程和应用的多线程同时进行。 注:吞吐量=运行用户代码时间/(运行用户代码时间+ 垃圾收集时间) 垃圾收集时间= 垃圾回收频率 * 单次垃圾回收时间

垃圾回收器 HostSpot 的细节实现

并发标记与三色标记
三色标记
在三色标记法之前有一个算法叫 Mark-And-Sweep(标记清除)。这个算法会设置一个标志位来记录对象是否被使用。最开始所有的标记位都是 0,如果 发现对象是可达的就会置为 1,一步步下去就会呈现一个类似树状的结果。等标记的步骤完成后,会将未被标记的对象统一清理,再次把所有的标记位 设置成 0 方便下次清理。
这个算法最大的问题是 GC 执行期间需要把整个程序完全暂停,不能异步进行 GC 操作。因为在不同阶段标记清扫法的标志位 0 和 1 有不同的含义, 那么新增的对象无论标记为什么都有可能意外删除这个对象。对实时性要求高的系统来说,这种需要长时间挂起的标记清扫法是不可接受的。所以就需 要一个算法来解决 GC 运行时程序长时间挂起的问题,那就三色标记法。
三色标记最大的好处是可以异步执行,从而可以以中断时间极少的代价或者完全没有中断来进行整个 GC。
三色标记法很简单。首先将对象用三种颜色表示,分别是白色、灰色和黑色。
黑色:根对象,或者该对象与它的子对象都被扫描过。
灰色:对本身被扫描,但是还没扫描完该对象的子对象。
白色:未被扫描对象,如果扫描完所有对象之后,最终为白色的为不可达对象,既垃圾对象。
三色标记的问题
并发情况下,可能会对标记的对象进行引用修改,导致GC 并发情况下的漏标问题。例如A对象已经被标记为了黑色,此时还未进行垃圾回收,而这时有一个线程把一个未标记的C对象的引用给了A对象,此时C对象就会被漏表。

CMS 中的解决方案
Incremental Update 算法 当一个白色对象被一个黑色对象引用,将黑色对象重新标记为灰色,让垃圾回收器重新扫描

G1 中的解决方案
SATB(snapshot-at-the-beginning)
刚开始做一个快照,当 B 和 C 消失的时候要把这个引用推到 GC 的堆栈,保证 C 还能被 GC 扫描到,最重要的是要把这个引用推到 GC 的堆栈,是灰色对 象指向白色的引用,如果一旦某一个引用消失掉了,我会把它放到栈(GC 方法运行时数据也是来自栈中),我其实还是能找到它的,我下回直接扫描他 就行了,那样白色就不会漏标。
对应 G1 的垃圾回收过程中的:
最终标记( Final Marking)
对用户线程做另一个短暂的暂停,用于处理并发阶段结后仍遗留下来的最后那少量的 SATB 记录(漏标对象)。
对比
SATB 算法是关注引用的删除。(B->C 的引用)
Incremental Update 算法关注引用的增加。(A->C 的引用)
G1 如果使用 Incremental Update 算法,因为变成灰色的成员还要重新扫,重新再来一遍,效率太低了。
所以 G1 在处理并发标记的过程比 CMS 效率要高,这个主要是解决漏标的算法决定的。

跨代引用
堆空间通常被划分为新生代和老年代。由于新生代的垃圾收集通常很频繁,如果老年代对象引用了新生代的对象,那么回收新生代的话,需要跟踪从老 年代到新生代的所有引用,所以要避免每次 YGC 时扫描整个老年代,减少开销。
RSet(记忆集)
记录了其他 Region 中的对象到本 Region 的引用, RSet 的价值在于使得垃圾收集器不需要扫描整个堆,找到谁引用了当前分区中的对象,只需要扫描 RSet 即可。 RSet 本身就是一个 Hash 表,如果是在 G1 的话,则是在一个 Region 区里面。
CardTable
由于做新生代 GC 时,需要扫描整个 OLD 区,效率非常低,所以 JVM 设计了 CardTable,如果一个 OLD 区 CardTable 中有对象指向 Y 区, 就将它设为 Dirty (标志位 1), 下次扫描时,只需要扫描 CARDTABLE 上是 Dirty 的内存区域即可。 字节数组 CARDTABLE 的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。 一般来说,卡页大小 都是以 2 的 N 次幂的字节数,假设使用的卡页是 2 的 10 次幂,即 1K,内存区域的起始地址是 0x0000 的话,数组 CARD_TABLE 的第 0、1、2 号元素,分别 对应了地址范围为 0x0000~0x03FF、0x0400 ~ 0x07FF、0x0800~0x011FF 的卡页内存。
总结
这里描述的是 G1 处理跨代引用的细节,其实在 CMS 中也有类似的处理方式,比如 CardTable,也需要记录一个 RSet 来记录,我们对比一下,在 G1 中是每 一个 Region 都需要一个 RSet 的内存区域,导致有 G1 的 RSet 可能会占据整个堆容量的 20%乃至更多。但是 CMS 只需要一份,所以就内存占用来说,G1
占用的内存需求更大,虽然 G1 的优点很多,但是我们不推荐在堆空间比较小的情况下使用 G1,尤其小于 6 个 G。

安全点与安全区域
安全点
用户线程暂停,GC 线程要开始工作,但是要确保用户线程暂停的这行字节码指令是不会导致引用关系的变化。所以 JVM 会在字节码指令中,选一些指令, 作为“安全点”,比如方法调用、循环跳转、异常跳转等,一般是这些指令才会产生安全点。 为什么它叫安全点,是这样的,GC 时要暂停业务线程,并不是抢占式中断(立马把业务线程中断)而是主动是中断。 主动式中断是设置一个标志,这个标志是中断标志,各业务线程在运行过程中会不停的主动去轮询这个标志,一旦发现中断标志为 True,就会在自己最近 的“安全点”上主动中断挂起。
安全区域
为什么需要安全区域? 要是业务线程都不执行(业务线程处于 Sleep 或者是 Blocked 状态),那么程序就没办法进入安全点,对于这种情况,就必须引入安全区域。 安全区域是指能够确保在某一段代码片段之中, 引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区 城看作被扩展拉伸了的安全点。
当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,这段时间里 JVM 要发起 GC 就不必去管这个线程了。 当线程要离开安全区域时,它要 JVM 是否已经完成了(根节点枚举,或者其他 GC 中需要暂停用户线程的阶段)
1、如果完成了,那线程就当作没事发生过,继续执行。
2、否则它就必须一直等待, 直到收到可以离开安全区域的信号为止。

GC 常用参数
-Xmn -Xms -Xmx –Xss 年轻代 最小堆 最大堆 栈空间
-XX:+UseTLAB 使用 TLAB,默认打开
-XX:+PrintTLAB 打印 TLAB 的使用情况
-XX:TLABSize 设置 TLAB 大小
-XX:+DisableExplicitGC 启用用于禁用对的调用处理的选项 System.gc()
-XX:+PrintGC 查看 GC 基本信息
-XX:+PrintGCDetails 查看 GC 详细信息
-XX:+PrintHeapAtGC 每次一次 GC 后,都打印堆信息
-XX:+PrintGCTimeStamps 启用在每个 GC 上打印时间戳的功能
-XX:+PrintGCApplicationConcurrentTime 打印应用程序时间(低)
-XX:+PrintGCApplicationStoppedTime 打印暂停时长(低)
-XX:+PrintReferenceGC 记录回收了多少种不同引用类型的引用(重要性低) -verbose:class 类加载详细过程
-XX:+PrintVMOptions 可在程序运行时,打印虚拟机接受到的命令行显示参数
-XX:+PrintFlagsFinal -XX:+PrintFlagsInitial 打印所有的 JVM 参数、查看所有 JVM 参数启动的初始值(必须会用)
-XX:MaxTenuringThreshold 升代年龄,最大值 15, 并行(吞吐量)收集器的默认值为 15,而 CMS 收集器的默认值为 6。
Parallel 常用参数
-XX:SurvivorRatio 设置伊甸园空间大小与幸存者空间大小之间的比率。默认情况下,此选项设置为 8
-XX:PreTenureSizeThreshold 大对象到底多大,大于这个值的参数直接在老年代分配 -XX:MaxTenuringThreshold 升代年龄,最大值 15, 并行(吞吐量)收集器的默认值为 15,而 CMS 收集器的默认值为 6。
-XX:+ParallelGCThreads 并行收集器的线程数,同样适用于 CMS,一般设为和 CPU 核数相同
-XX:+UseAdaptiveSizePolicy 自动选择各区大小比例 CMS 常用参数
-XX:+UseConcMarkSweepGC 启用 CMS 垃圾回收器
-XX:+ParallelGCThreads 并行收集器的线程数,同样适用于 CMS,一般设为和 CPU 核数相同
-XX:CMSInitiatingOccupancyFraction 使用多少比例的老年代后开始 CMS 收集,默认是 68%(近似值),如果频繁发生 SerialOld 卡顿,应该调小,(频繁 CMS 回 收)
-XX:+UseCMSCompactAtFullCollection 在 FGC 时进行压缩 -XX:CMSFullGCsBeforeCompaction 多少次 FGC 之后进行压缩
-XX:+CMSClassUnloadingEnabled 使用并发标记扫描(CMS)垃圾收集器时,启用类卸载。默认情况下启用此选项。
-XX:CMSInitiatingPermOccupancyFraction 达到什么比例时进行 Perm 回收,JDK 8 中不推荐使用此选项,不能替代。
-XX:GCTimeRatio 设置 GC 时间占用程序运行时间的百分比(不推荐使用)
-XX:MaxGCPauseMillis 停顿时间,是一个建议时间,GC 会尝试用各种手段达到这个时间,比如减小年轻代 G1 常用参数
-XX:+UseG1GC 启用 CMS 垃圾收集器
-XX:MaxGCPauseMillis 设置最大 GC 暂停时间的目标(以毫秒为单位)。这是一个软目标,并且 JVM 将尽最大的努力(G1 会尝试调整 Young 区的块数来)来实 现它。默认情况下,没有最大暂停时间值。
-XX:GCPauseIntervalMillis GC 的间隔时间
-XX:+G1HeapRegionSize 分区大小,建议逐渐增大该值,1 2 4 8 16 32。随着 size 增加,垃圾的存活时间更长,GC 间隔更长,但每次 GC 的时间也会更长
-XX:G1NewSizePercent 新生代最小比例,默认为 5%
-XX:G1MaxNewSizePercent 新生代最大比例,默认为 60%
-XX:GCTimeRatioGC 时间建议比例,G1 会根据这个值调整堆空间
-XX:ConcGCThreads 线程数量
-XX:InitiatingHeapOccupancyPercent 启动 G1 的堆空间占用比例,根据整个堆的占用而触发并发 GC 周期

Class 文件结构及深入字节码指令

JVM 的无关性
与平台无关性是建立在操作系统上,虚拟机厂商提供了许多可以运行在各种不同平台的虚拟机,它们都可以载入和执行字节码,从而实现程序的“一次 编写,到处运行”
https://www.oracle.com/technetwork/java/javase/downloads/jdk8-downloads-2133151.html
各种不同平台的虚拟机与所有平台都统一使用的程序存储格式——字节码(ByteCode)是构成平台无关性的基石,也是语言无关性的基础。Java 虚拟机不 和包括 Java 在内的任何语言绑定,它只与“Class 文件”这种特定的二进制文件格式所关联,Class 文件中包含了 Java 虚拟机指令集和符号表以及若干其他 辅助信息。
Class 类文件
Java 技术能够一直保持非常好的向后兼容性,这点 Class 文件结构的稳定性功不可没。Java 已经发展到 14 版本,但是 class 文件结构的内容,绝大部分在 JDK1.2 时代就已经定义好了。虽然 JDK1.2 的内容比较古老,但是 java 发展经历了十余个大版本,但是每次基本上知识在原有结构基础上新增内容、扩充 功能,并未对定义的内容做修改。
任何一个 Class 文件都对应着唯一一个类或接口的定义信息,但反过来说,Class 文件实际上它并不一定以磁盘文件的形式存在(比如可以动态生成、或者 直接送入类加载器中)。
Class 文件是一组以 8 位字节为基础单位的二进制流。
Class 文件结构这些内容在面试的时候很少有人问,因此大家学这个东西要当成一个兴趣去学,这个是自身内力提升的过程。
工具介绍
Sublime
查看 16 进制的编辑器
javap
javap 是 JDK 自带的反解析工具。它的作用是将 .class 字节码文件解析成可读的文件格式。 在使用 javap 时我一般会添加 -v 参数,尽量多打印一些信息。同时,我也会使用 -p 参数,打印一些私有的字段和方法。
jclasslib
如果你不太习惯使用命令行的操作,还可以使用 jclasslib,jclasslib 是一个图形化的工具,能够更加直观的查看字节码中的内容。它还分门别类的对类中 的各个部分进行了整理,非常的人性化。同时,它还提供了 Idea 的插件,你可以从 plugins 中搜索到它。 jclasslib 的下载地址:https://github.com/ingokegel/jclasslib
Class 文件格式
从一个 Class 文件开始,下图是一个 java 文件。

public class ByteCode{
  ByteCode(){}
}

我们使用 Sublime 这个工具打开 class
在这里插入图片描述
整个 class 文件的格式就是一个二进制的字节流。
那么这个二进制的字节流就看谁来解释了,我做了一个 Xmind 文件。
各个数据项目严格按照顺序紧凑地排列在 Class 文件之中,中间没有添加任何分隔符,这使得整个 Class 文件中存储的内容几乎全部是程序运行的必要数 据,没有空隙存在。
Class 文件格式采用一种类似于 C 语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表。
无符号数属于基本的数据类型,以 u1、u2、u4、u8 来分别代表 1 个字节(一个字节是由两位 16 进制数组成)、2 个字节、4 个字节和 8 个字节的无符号
数,无符号数可以用来描述数字、索引引用、数量值或者按照 UTF-8 编码构成字符串值。
表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个 Class 文件本质上就是一张表。

Class 文件格式详解
Class 的结构不像 XML 等描述语言,由于它没有任何分隔符号,所以在其中的数据项,无论是顺序还是数量,都是被严格限定的,哪个字节代表什么含义, 长度是多少,先后顺序如何,都不允许改变。
按顺序包括:

  1. 魔数与 Class 文件的版本
    每个 Class 文件的头 4 个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的 Class 文件。使用魔数而不是扩展 名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意地改动。文件格式的制定者可以自由地选择魔数值,只要这个魔数值还没有被广泛 采用过同时又不会引起混淆即可。(CA FE BA BE)
    紧接着魔数的 4 个字节存储的是 Class 文件的版本号:第 5 和第 6 个字节是次版本号(MinorVersion),第 7 和第 8 个字节是主版本号(Major Version)。 Java 的版本号是从 45 开始的,JDK 1.1 之后的每个 JDK 大版本发布主版本号向上加 1 高版本的 JDK 能向下兼容以前版本的 Class 文件,但不能运行以后版 本的 Class 文件,即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的 Class 文件。(00 00 00 34)代表JDK1.8(16进制的34,缓存十进制是52)
  2. 常量池
    常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项 u2 类型的数据,代表常量池容量计数值(constant_pool_count)。 与 Java 中语言习惯不一样的是,这个容量计数是从 1 而不是 0 开始的。
    在这里插入图片描述

常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。
字面量比较接近于 Java 语言层面的常量概念,如文本字符串、声明为 final 的常量值等。
而符号引用则属于编译原理方面的概念,包括了下面三类常量: 类和接口的全限定名(Fully Qualified Name)、字段的名称和描述符(Descriptor)、方法的名称和描述符。
在这里插入图片描述
我们就可以使用更加直观的工具 jclasslib,来查看字节码中的具体内容了。
在这里插入图片描述

  1. 访问标志
    用于识别一些类或者接口层次的访问信息,包括:这个 Class 是类还是接口;是否定义为 public 类型;是否定义为 abstract 类型;如果是类的话,是否被 声明为 final 等

  2. 类索引、父类索引与接口索引集合
    这三项数据来确定这个类的继承关系。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。由于 Java 语言不允许多重继承, 所以父类索引只有一个,除了 java.lang.Object 之外,所有的 Java 类都有父类,因此除了 java.lang.Object 外,所有 Java 类的父类索引都不为 0。接口索引 集合就用来描述这个类实现了哪些接口,这些被实现的接口将按 implements 语句(如果这个类本身是一个接口,则应当是 extends 语句)后的接口顺序 从左到右排列在接口索引集合中

  3. 字段表集合
    描述接口或者类中声明的变量。字段(field)包括类级变量以及实例级变量。 而字段叫什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。 字段表集合中不会列出从超类或者父接口中继承而来的字段,但有可能列出原本 Java 代码之中不存在的字段,譬如在内部类中为了保持对外部类的访问 性,会自动添加指向外部类实例的字段。

  4. 方法表集合
    描述了方法的定义,但是方法里的 Java 代码,经过编译器编译成字节码指令后,存放在属性表集合中的方法属性表集合中一个名为“Code”的属性里面。 与字段表集合相类似的,如果父类方法在子类中没有被重写(Override),方法表集合中就不会出现来自父类的方法信息。但同样的,有可能会出现由编
    译器自动添加的方法,最典型的便是类构造器“<clinit>”方法和实例构造器“<init>”

  5. 属性表集合
    存储 Class 文件、字段表、方法表都自己的属性表集合,以用于描述某些场景专有的信息。如方法的代码就存储在 Code 属性表中。

  6. 字节码指令
    Java 虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需参数(称为操 作数,Operands)而构成。 由于限制了 Java 虚拟机操作码的长度为一个字节(即 0~255),这意味着指令集的操作码总数不可能超过 256 条。 大多数的指令都包含了其操作所对应的数据类型信息。例如: iload 指令用于从局部变量表中加载 int 型的数据到操作数栈中,而 fload 指令加载的则是 float 类型的数据。 大部分的指令都没有支持整数类型 byte、char 和 short,甚至没有任何指令支持 boolean 类型。大多数对于 boolean、byte、short 和 char 类型数据的操作,
    实际上都是使用相应的 int 类型作为运算类型 阅读字节码作为了解 Java 虚拟机的基础技能,有需要的话可以去掌握常见指令。 字节码助记码解释地址:https://cloud.tencent.com/developer/article/1333540

  7. 加载和存储指令
    用于将数据在栈帧中的局部变量表和操作数栈之间来回传输,这类指令包括如下内容。 将一个局部变量加载到操作栈:iload、iload_<n>、lload、lload_<n>、fload、fload_<n>、dload、dload_<n>、aload、aload_<n>。
    将一个数值从操作数栈存储到局部变量表:istore、istore_<n>、lstore、lstore_<n>、fstore、fstore_<n>、dstore、dstore_<n>、astore、astore_<n >。
    将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>。
    扩充局部变量表的访问索引的指令:wide。

  8. 运算或算术指令
    用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。
    加法指令:iadd、ladd、fadd、dadd。
    减法指令:isub、lsub、fsub、dsub。
    乘法指令:imul、lmul、fmul、dmul 等等

  9. 类型转换指令
    可以将两种不同的数值类型进行相互转换, Java 虚拟机直接支持以下数值类型的宽化类型转换(即小范围类型向大范围类型的安全转换):
    int 类型到 long、float 或者 double 类型。
    long 类型到 float、double 类型。
    float 类型到 double 类型。
    处理窄化类型转换(Narrowing Numeric Conversions)时,必须显式地使用转换指令来完成,这些转换指令包括:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l 和 d2f

  10. 创建类实例的指令
    new。

  11. 创建数组的指令
    newarray、anewarray、multianewarray。

  12. 访问字段指令
    getfield、putfield、getstatic、putstatic。

  13. 数组存取相关指令
    把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、daload、aaload。
    将一个操作数栈的值存储到数组元素中的指令:bastore、castore、sastore、iastore、fastore、dastore、aastore。
    取数组长度的指令:arraylength。

  14. 检查类实例类型的指令
    instanceof、checkcast。

  15. 操作数栈管理指令
    如同操作一个普通数据结构中的堆栈那样,Java 虚拟机提供了一些用于直接操作操作数栈的指令,包括:将操作数栈的栈顶一个或两个元素出栈:pop、 pop2。
    复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2。
    将栈最顶端的两个数值互换:swap。

  16. 控制转移指令
    控制转移指令可以让 Java 虚拟机有条件或无条件地从指定的位置指令而不是控制转移指令的下一条指令继续执行程序,从概念模型上理解,可以认为控 制转移指令就是在有条件或无条件地修改 PC 寄存器的值。控制转移指令如下。
    条件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq 和 if_acmpne。
    复合条件分支:tableswitch、lookupswitch。
    无条件分支:goto、goto_w、jsr、jsr_w、ret。

  17. 方法调用指令
    invokevirtual 指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是 Java 语言中最常见的方法分派方式。
    invokeinterface 指令用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。
    invokespecial 指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。
    invokestatic 指令用于调用类方法(static 方法)。
    invokedynamic 指令用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法,前面 4 条调用指令的分派逻辑都固化在 Java 虚拟机内部,而 invokedynamic 指令的分派逻辑是由用户所设定的引导方法决定的。
    方法调用指令与数据类型无关。

  18. 方法返回指令
    是根据返回值的类型区分的,包括 ireturn(当返回值是 boolean、byte、char、short 和 int 类型时使用)、lreturn、freturn、dreturn 和 areturn,另外还有 一条 return 指令供声明为 void 的方法、实例初始化方法以及类和接口的类初始化方法使用。

  19. 异常处理指令
    在 Java 程序中显式抛出异常的操作(throw 语句)都由 athrow 指令来实现

  20. 同步指令
    有 monitorenter 和 monitorexit 两条指令来支持 synchronized 关键字的语义

  21. 字节码指令——异常处理
    每个时刻正在执行的当前方法就是虚拟机栈顶的栈桢。方法的执行就对应着栈帧在虚拟机栈中入栈和出栈的过程。 当一个方法执行完,要返回,那么有两种情况,一种是正常,另外一种是异常。
    完成出口(返回地址):
    正常返回:(调用程序计数器中的地址作为返回)
    三步曲:
    恢复上层方法的局部变量表和操作数栈、
    把返回值(如果有的话)压入调用者栈帧的操作数栈中、
    调整程序计数器的值以指向方法调用指令后面的一条指令、
    异常的话:(通过异常处理表<非栈帧中的>来确定)

  22. 异常机制
    如果你熟悉 Java 语言,那么对上面的异常继承体系一定不会陌生,其中,Error 和 RuntimeException 是非检查型异常(Unchecked Exception),也就是 不需要 catch 语句去捕获的异常;而其他异常,则需要程序员手动去处理。

  23. 异常表
    在这里插入图片描述
    在这里插入图片描述
    在 synchronized 生成的字节码中,其实包含两条 monitorexit 指令,是为了保证所有的异常条件,都能够退出。
    可以看到,编译后的字节码,带有一个叫 Exception table 的异常表,里面的每一行数据,都是一个异常处理器:
    from 指定字节码索引的开始位置
    to 指定字节码索引的结束位置
    target 异常处理的起始位置
    type 异常类型
    也就是说,只要在 from 和 to 之间发生了异常,就会跳转到 target 所指定的位置。
    我可以看到,第一条 monitorexit(16)在异常表第一条的范围中,如果异常,能够跳转到第 20 行
    第二条 monitorexit(22)在异常表第二条的范围中,如果异常,能够跳转到第 20 行

  24. Finally
    通常我们在做一些文件读取的时候,都会在 finally 代码块中关闭流,以避免内存的溢出。关于这个场景,我们再分析一下下面这段代码的异常表。
    在这里插入图片描述
    上面的代码,捕获了一个 FileNotFoundException 异常,然后在 finally 中捕获了 IOException 异常。当我们分析字节码的时候,却发现了一个有意思的地 方:IOException 足足出现了三次。
    在这里插入图片描述
    在这里插入图片描述
    Java 编译器使用了一种比较傻的方式来组织 finally 的字节码,它分别在 try、catch 的正常执行路径上,复制一份 finally 代码,追加在正常执行逻辑的 后面;同时,再复制一份到其他异常执行逻辑的出口处。
    再看一个例子:
    这段代码不报错的原因,都可以在字节码中找到答案
    在这里插入图片描述
    程序的字节码,可以看到,异常之后,直接跳转到序号 9 了。
    在这里插入图片描述
    字节码指令——装箱拆箱
    装箱拆箱
    Java 中有 8 种基本类型,但鉴于 Java 面向对象的特点,它们同样有着对应的 8 个包装类型,比如 int 和 Integer,包装类型的值可以为 null(基本类 型没有 null 值,而数据库的表中普遍存在 null 值。 所以实体类中所有属性均应采用封装类型),很多时候,它们都能够相互赋值。
    在这里插入图片描述
    在这里插入图片描述
    通过观察字节码,我们发现:
    1、在进行乘法运算的时候,调用了 Integer.intValue 方法来获取基本类型的值。
    2、赋值操作使用的是 Integer.valueOf 方法。
    3、在方法返回的时候,再次使用了 Integer.valueOf 方法对结果进行了包装。 这就是 Java 中的自动装箱拆箱的底层实现。
    IntegerCache
    但这里有一个陷阱问题,我们继续跟踪 Integer.valueOf 方法
    在这里插入图片描述
    这个 IntegerCache,缓存了 low 和 high 之间的 Integer 对象
    在这里插入图片描述
    一般情况下,缓存是的-128 到 127 之间的值,但是可以通过 -XX:AutoBoxCacheMax 来修改上限。
    下面是一道经典的面试题,请考虑一下运行代码后,会输出什么结果?
    在这里插入图片描述
    一般情况下是是 true,false 因为缓存的原因。(在缓存范围内的值,返回的是同一个缓存值,不在的话,每次都是 new 出来的) 当我加上 VM 参数 -XX:AutoBoxCacheMax=256 执行时,结果是 true,ture,扩大缓存范围,第二个为 true 原因就在于此。
    字节码指令——数组
    其实,数组是 JVM 内置的一种对象类型,这个对象同样是继承的 Object 类。我们使用代码来理解一下
    在这里插入图片描述
    在这里插入图片描述
    数组创建
    可以看到,新建数组的代码,被编译成了 newarray 指令
    在这里插入图片描述
    数组里的初始内容,被顺序编译成了一系列指令放入:
    sipush 将一个短整型常量值推送至栈顶;
    iastore 将栈顶 int 型数值存入指定数组的指定索引位置。
    在这里插入图片描述
    具体操作:
    1、 iconst_0,常量 0,入操作数栈
    2、 sipush 将一个常量 1111 加载到操作数栈
    3、 将栈顶 int 型数值存入数组的 0 索引位置
    为了支持多种类型,从操作数栈存储到数组,有更多的指令:bastore、castore、sastore、iastore、lastore、fastore、dastore、aastore。
    数组访问
    在这里插入图片描述

数组元素的访问,是通过第 28 ~ 30 行代码来实现的:
aload_1 将第二个引用类型本地变量推送至栈顶,这里是生成的数组;
iconst_2 将 int 型 2 推送至栈顶;
iaload 将 int 型数组指定索引的值推送至栈顶。
获取数组的长度,是由字节码指令 arraylength 来完成的
在这里插入图片描述
获取数组长度的指令 arraylength

字节码指令——foreach
无论是 Java 的数组,还是 List,都可以使用 foreach 语句进行遍历,虽然在语言层面它们的表现形式是一致的,但实际实现的方法并不同。
在这里插入图片描述
数组:它将代码解释成了传统的变量方式,即 for(int i;i<length;i++) 的形式。
List 的它实际是把 list 对象进行迭代并遍历的,在循环中,使用了 Iterator.next() 方法。
使用 jd-gui 等反编译工具,可以看到实际生成的代码
字节码指令——注解
在这里插入图片描述
在这里插入图片描述
无论是类的注解,还是方法注解,都是由一个叫做 RuntimeInvisibleAnnotations 的结构来存储的,而参数的存储,是由 RuntimeInvisibleParameterAnotations 来保证的

玩转类加载与类加载器

一个类的生命周期
类生命周期 7 个阶段
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、 初始化(Initialization)、使用(Using)和卸载(Unloading)7 个阶段。其中验证、准备、解析 3 个部分统称为连接(Linking)

阶段顺序
加载、校验、准备、初始化和卸载这五个阶段的顺序是确定的,但是对于“解析”阶段则不一定,它在某些情况下可以在初始化之后再开始,这样做是 为了支持 java 的运行时绑定特征(也称为动态绑定或晚期绑定)。
加载的时机
什么是需要开始类第一个阶段“加载”,虚拟机规范没有强制约束,这点交给虚拟机的具体实现来自由把控。
JVM 虚拟机的实现都是使用的懒加载,就是什么时候需要这个类了我才去加载,并不是说一个 jar 文件里面有 200 多个类,但实际我只用到了其中的一个 类,我不需要把 200 多个类全部加载进来。(如果你自己写一个 JVM 倒是可以这么干!)
“加载 loading”阶段是整个类加载(class loading)过程的一个阶段。
加载阶段虚拟机需要完成以下 3 件事情:
1)通过一个类的全限定名来获取定义此类的二进制字节流。
2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3)在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
注意:比如“通过一个类的全限定名来获取定义此类的二进制字节流”没有指定一定得从某个 class 文件中获取,所以我们可以从 zip 压缩包、从网络中 获取、运行时计算生成、数据库中读取、或者从加密文件中获取等等。 我们也可以通过前面的工具 JHSDB 可以看到,JVM 启动后,相关的类已经加载进入了方法区,成为了方法区的运行时结构。
验证
是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。但从整体 上看,验证阶段大致上会完成下面 4 个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。

文件格式验证
第一阶段要验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理。这一阶段可能包括下面这些验证点:
1、是否以魔数 OxCAFEBABE 开头。
2、主、次版本号是否在当前 Java 虚拟机接受范围之内
3、常量池的常量中是否有不被支持的常量类型(检查常量 tag 标志)。
4、指向常量的各种索引值中是否有指向不存在的常量或不符合类型的
5、CONSTANT Utf8 info 型的常量中是否有不符合 UTF-8 编码的数据。
6、Class 文件中各个部分及文件本身是否有被删除的或附加的其他信息。

以上的部分还只是一小部分,没必要进行深入的研究。

总结一下: 这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证之后,这段字节流才被允许进

元数据验证
第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java 语言规范》的要求,这个阶段可能包括的验证点如下:
1、这个类是否有父类(除了 java.lang.Object 之外,所有的类都应当有父类)。
2、这个类的父类是否继承了不允许被继承的类(被 final 修饰的类)。
3、如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
4、类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的 final 字段,或者出现不符合规则的方法重载,例如方法参数都-致,但返回值类型却不同等)。

以上的部分还只是一小部分,没必要进行深入的研究。 元数据验证是验证的第二阶段,主要目的是对类的元数据信息进行语义校验,保证不存在与《Java 语言规范》定义相悖的元数据信息。
字节码验证
字节码验证第三阶段是整个验证过程中最复杂的一一个阶段, 主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。在第二 阶段对元数据信息中的数据类型校验完毕以后,这阶段就要对类的方法体(Class 文件中的 Code 属性)进行校验分析,保证被校验类的方法在运行时不会做 出危害虚拟机安全的行为,例如:
1、保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于“在操作栈放置了一个 int 类型的数据,使用时却按 long 类型 来加载入本地变量表中”这样的情况。
2、 保证任何跳转指令都不会跳转到方法体以外的字节码指令上。
3、保证方法体中的类型转换总是有效的,例如可以把-个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至把对 象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的。

以上的部分还只是一小部分,没必要进行深入的研究。 如果一个类型中有方法体的字节码没有通过字节码验证,那它肯定是有问题的。
符号引用验证
最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段一解析阶段中发生。符号引用验证可以看 作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字
段等资源。本阶段通常需要校验下列内容:
1、符号引用中通过字符串描述的全限定名是否能找到对应的类。
2、 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
3、 符号引用中的类、字段、方法的可访问性( private、 protected. public、 )
4、 是否可被当前类访问。

符号引用验证的主要目的是确保解析行为能正常执行,如果无法通过符号引用验证,将会抛出异常。
验证(总结)
验证阶段对于虚拟机的类加载机制来说,是一个非常重要的、 但却不是必须要执行的阶段,因为验证阶段只有通过或者不通过的差别,只要通过了验证, 其后就对程序运行期没有任何影响了。如果程序运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都已经被反复 使用和验证过,在生产环境的实施阶段就可以考虑使用-Xverify:none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

准备
准备阶段是正式为类中定义的变量(被 static 修饰的变量)分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。 这个阶段中有两个容易产生混淆的概念需要强调一下:
首先,这时候进行内存分配的仅包括类变量(被 static 修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。 其次,这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义为:public static int value=123;
那变量 value 在准备阶段过后的初始值为 0 而不是 123,因为这时候尚未开始执行任何 Java 方法,而把 value 赋值为 123 是后续的初始化环节。
基本数据类型的零值表

数据类型零值数据类型零值
int0booleanfalse
long0Lfloat0.0f
short(short) 0double0.0d
char‘\u0000’referencenull
byte(byte)0

解析
解析阶段是 JVM 将常量池内的符号引用替换为直接引用的过程。 符号引用是一种定义,可以是任何字面上的含义,而直接引用就是直接指向目标的指针、相对偏移量。 直接引用的对象都存在于内存中,你可以把通讯录里的女友手机号码,类比为符号引用,把面对面和你吃饭的女朋友,类比为直接引用。
解析大体可以分为:
1、类或接口的解析
2、字段解析
3、类方法解析
4、接口方法解析
我们了解几个经常发生的异常,就与这个阶段有关。
java.lang.NoSuchFieldError 根据继承关系从下往上,找不到相关字段时的报错。(字段解析异常) java.lang.IllegalAccessError 字段或者方法,访问权限不具备时的错误。(类或接口的解析异常) java.lang.NoSuchMethodError 找不到相关方法时的错误。(类方法解析、接口方法解析时发生的异常)

初始化
初始化主要是对一个 class 中的 static{}语句进行操作(对应字节码就是 clinit 方法)。
<clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit >()方法。
初始化阶段,虚拟机规范则是严格规定了有且只有 6 种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):
1)遇到 new、getstatic、putstatic 或 invokestatic 这 4 条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这 4 条指令的最常见的 Java 代码场景是:  使用 new 关键字实例化对象的时候。  读取或设置一个类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)的时候  调用一个类的静态方法的时候。
2)使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
3)当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
4)当虚拟机启动时,用户需要指定一个要执行的主类(包含 main()方法的那个类),虚拟机会先初始化这个主类。
5)当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法 句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。
6)当一个接口中定义了 JDK1.8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果这个接口的实现类发生了初始化,那该接口要在其之前 被初始化。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值