深入理解JVM虚拟机(总结篇)

JVM平台上还可以运行其他语言,运行的是Class字节码。只要能翻译成Class的语言就OK了。挺强大的。

  • JVM厂商很多
  • 垃圾收集器、收集算法
  • JVM检测工具

 

关于类的加载:

  •  Java代码中,类型(interface, class,enum等,有些是在运行时候生成的,比如动态代理)的加载、连接与初始化过程都是在程序运行期间完成的。不涉及到对象的概念。同时也是个Runtime阶段。
  •  提供了更大的灵活性,增加了更多的可能性。提供了一些扩展,灵活扩展。

    

Java虚拟机与程序的生命周期:

  在如下几种情况下,Java虚拟机将会结束生命周期:

  1. 执行了System.exit()方法
  2. 程序正常执行结束
  3. 程序执行过程遇到了异常或者错误异常终止了
  4. 操作系统出现错误导致Java虚拟机进行终止

 

类的加载、连接与初始化:

加载:查找并加载类的二进制数据

连接: 

  • 验证: 确保被加载类的正确性。Class有格式的。
  • 准备:为类的静态变量分配内存,并将其初始化为默认值  
  • 注:
    1.类的静态变量或类的静态方法,通常可以看做全局的,由类去直接调用。此时还是个类的概念,不存在对象。
    2.关于默认值问题:
    class Test{
    public static int a = 1;
    }
    中间过程: Test类加载到内存的过程中,会给a分配一个内存。然后将a初始化为默认值0(整型变量)

  • 解析: 把类中的符号引用转为直接引用。符号的引用也是间接的引用方式。

初始化: 为类的静态变量赋予正确的初始值

  • class Test{
       public static int a = 1;
      }
    此时的a才真正成为1了
    

      

类的使用与卸载

 使用: 类的方法变量使用等

 卸载: class字节码文件,加载到内存里面。形成了自己的数据结构,驻留在内存里面。可以销毁掉。卸载到了就不能进行new 对象了。

 

总体流程:

 

 

 

Java程序对类的使用方式分为两种:

  1. 主动使用
  2. 被动使用

 

所有的Java虚拟机实现必须在每个类或接口被Java程序“首次主动使用”时才初始化他们。即初始化只会执行一次。

 

主动使用,七种(非精确划分,大体划分):

  1. 创建类的实例。
  2. 访问某个类或接口的静态变量,或者对静态变量赋值。 字节码层面上,使用的助记符:get static、  put static
  3. 调用类的静态方法。 invoke static
  4. 反射(如Class.forName("com.test.t1"))
  5. 初始化一个类的子类
    比如:
    
     class Parent{}
     class Child extends Parent{}
    
    初始化Child时候,先去初始化Parent 
  6. Java虚拟机启动时被表明为启动类的类(Java Test)
    Java虚拟机启动时候,被标明为启动的类,即为有main方法的类,也会主动使用 
  7. JDK1.7开始提供动态语言支持:
    注:
    1.java.lang.invoke.MethodHandle实例的解析结果REF_getStatic, REF_putStatic, REF_invokeStatic句柄对应的类没有初始化,则初始化
    2.1.7开始提供了对动态语言的支持。特别的JVM平台上通过脚本引擎调用JS代码(动态语言)。  

:助记符了解即可

 

除了以上七种情况,其他使用Java类的方式都被看做是对类的被动使用,都不会导致类的初始化

 

类的加载:

 类的加载指的是将类 .class文件中的二进制数据读入内存中,将其放在运行时数据区的方法区内,然后在内存中创建一个java.lang.Class对象(规范并说明Class对象位于哪里,HotSpot虚拟机将其放在了方法区中,JVM没有规范这个)用来封装类在方法区内的数据结构

 引申:一个类不管生成了多少实例,所有的实例对应只有一份Class对象。 Class对象是面镜子,能反映到方法区中的Class文件的内容、结构等各种信息。

加载.class文件的方式:

  1. 从本地系统中直接加载
  2. 通过网络下载
  3. 从zip、jar等贵方文件中加载
  4. 从专有数据库中提取
  5. 将Java源文件动态编译为.class文件

 

public class MyTest1 {
    public static void main(String[] args) {
        System.out.println(MyChild1.str1);
//        System.out.println(MyChild1.str2);
    }
}

class MyParent1{

    //静态成员变量
    public static String str1 = "str1";
    // 静态代码块(程序加载初始化时候去执行)
    static {
        System.out.println("MyParent1 -----> static block running");
    }
}
class MyChild1 extends MyParent1{
    //静态成员变量
    public static String str2 = "str2";
    static {
        System.out.println("MyChild1 -----> static block running");
    }
}

  

 

 

 

str1 子类调用了继承到的父类的str1,子类的静态代码块没有执行。str1是父类中定义的。MyParent1的主动使用,但是没有主动使用MyChild1. 总结:看定义的!

 

 

str2 可以执行,同时初始化子类时候,父类会主动使用。所有的父类都会被初始化

 

MyTest1是一个启动类,主动使用。先加载之。 

 

总结: 

  1. 对于静态字段来说,只有直接定义了该字段的类才会被初始化。
  2. 当一个类在初始化时候,要求其父类全部已经初始化完毕。每个父类最多只能初始化一次! 

 

引申: -XX:+TraceClassLoading,用于追踪类的加载信息并打印出来。可以看到类的加载情况。

           打印: 虚拟机在当前启动情况下所加载的类的信息。

 

总结设置方式:

所有JVM参数都是: -XX: 开头  

  类似于Boolean类型的开关:

        -XX:+<option> 表示开启option选项

        -XX: - <option>  表示关闭option选项

  赋值:   

     -XX:<option>=<value>, 表示将option选项的值设置为value  

 

 

 关于常量:

public class MyTest2 {

    public static void main(String[] args) {
        System.out.println(MyParent2.str);
    }
}

class MyParent2{

    // final修饰成为常量
    public static final String str = "hello world";

    static {
        System.out.println("MyParent2 ----> run");
    }

}

 

 

 

 

在编译阶段这个常量被存入到 调用这个常量的方法所在的类的常量池中

本例中:

   “hello world”是一个常量,会放置到MyTest2类的常量池中。

   这里指的时将常量存放到了MyTest2的常量池汇总,之后MyTest2与MyParent2就没有任何关系了

   甚至,极端一些。我们可以将MyParent3的class文件删除。(编译完毕后,把class字节码删除

 

总结:

  •  常量编译阶段会存入到调用这个常量的方法所在的类的常量池中。
  •  本质上,调用类并没有直接引用到定义常量的类,因此并不会触发定义常量类的初始化。

 

引申反编译: javap -c  类的全路径名字

 

助记符引申:

  • ldc表示将int,float 或 String类型的常量值从常量池中推送至栈顶。
  • bipush表示将单字节(-128 ~ 127)的常量值推送至栈顶  
  • sipush表示将一个短整型常量值(-32768 ~ 32767)推送至栈顶
  • iconst_1 表示将int类型的1推送至栈顶 (iconst_1 ~ iconst_5)

助记符是在rt.jar中相关类去实现的。

 

 

如果常量的值,在编译器不能确定下来呢?

public class MyTest3 {
    public static void main(String[] args) {
        System.out.println(MyParent3.str);
    }
}

class MyParent3 {
    public static final String str = UUID.randomUUID().toString();
    static {
        System.out.println("MyParent3 -- run");
    }
}

 

 

此时放在MyTest3类的常量池中没有意义的。

 

总结:  

  当一个常量值并非编译期间可以确定的,那么其值就不会被放到调用类的常量池中。这时在程序运行时,会导致主动使用这个常量所在的类,显然会导致这个类被初始化。

 

new对象实例情况:

public class MyTest4 {
    public static void main(String[] args) {
        MyParent4 myParent4 = new MyParent4();
    }
}
class MyParent4{
    static {
        System.out.println("MyParent4 --> run");
    }
}

对这个类的主动使用。

如果多次new,只会初始化一次。首次主动使用。

 

数组情况:

public class MyTest4 {
    public static void main(String[] args) {
        MyParent4[] myParent4s = new MyParent4[1];
    }
}
class MyParent4{
    static {
        System.out.println("MyParent4 --> run");
    }
}

不在七种情况范围内。不会初始化!

不是MyParent4的实例!

到底创建的什么实例?getClass!,数组的实例到底是个啥玩意儿?

public class MyTest4 {
    public static void main(String[] args) {
        MyParent4[] myParent4s = new MyParent4[1];
        //看看是啥
        Class<? extends MyParent4[]> aClass = myParent4s.getClass();
        System.out.println(aClass);
    }
}
class MyParent4{
    static {
        System.out.println("MyParent4 --> run");
    }
}

Java虚拟机在运行期,创建出来的类型。是个数组类型。有点类似动态代理

 

数组类型也是比较特殊的。[Lxxxx

二维数组也是一样的特殊

 

看下父类型:

public class MyTest4 {
    public static void main(String[] args) {
        MyParent4[] myParent4s = new MyParent4[1];
        //看看是啥
        System.out.println(myParent4s.getClass().getSuperclass());
    }
}
class MyParent4{
    static {
        System.out.println("MyParent4 --> run");
    }
}

父类型其实是Object

 

 

 

总结:

 对于数组实例来说,其类型是由JVM在运行期动态生成的

 动态生成的类型,其父类就是Object

 对于数组来说,JavaDoc经常将构成数组的元素为Component,实际上就是将数组降低一个维度后的类型。

 

看下原生类型的数组:

public class MyTest4 {
    public static void main(String[] args) {
       int[] ints = new int[3];
        System.out.println(ints.getClass());
        System.out.println(ints.getClass().getSuperclass());
    }
}
class MyParent4{
    static {
        System.out.println("MyParent4 --> run");
    }
}

 

 

 

助记符:

  anewarray: 表示创建一个引用类型的(比如类、接口、数组)数组,并将其引用值压如栈顶。

  newarray: 表示创建一个指定的原始类型(如:int,float,char等)的数组,并将其引用值压入栈顶。

 

以上所总结的是类与类之间的关系,包括继承的。下面接口的特点:

public class MyTest5 {
    public static void main(String[] args) {
        System.out.println(MyChild5.b);
    }
}

interface MyParent5 {
    public static int a = 5;
}

interface MyChild5 extends MyParent5 {
    public static int b = 6;
}

  接口是没有静态代码块的。可以通过手动删除class文件来证明之。

 

public class MyTest5 {
    public static void main(String[] args) {
        System.out.println(MyChild5.b);
    }
}

interface MyParent5 {
    public static int a = 5;
}

interface MyChild5 extends MyParent5 {
    // 只有在运行时候才会赋值,会放到MyTest5的常量池里面。如果Class删除了,运行时候就会报错!
    public static int b = new Random().nextInt(2);
}

 

  结论:

  • 当一个接口在初始化时候,并不要求其父类接口都完成了初始化。
  • 只有在真正使用到父类接口的时候(如引用接口中定义的常量时),才会初始化。
  • 类,一定要先初始化父类。

 

public class MyTest6 {
    public static void main(String[] args) {
        Singleton instance = Singleton.getInstance();
        System.out.println("counter"+ instance.counter1);
        System.out.println("counter"+ instance.counter2);
    }
}
class Singleton{
    public static int counter1;
    public static int counter2 = 0;

   private static Singleton singleton = new Singleton();

   private Singleton(){
       counter1++;
       counter2++;
   }
   public static Singleton getInstance(){
       return singleton;
   }
}

 

 

分析: 先赋值: 默认的0 和 给定的0,然后构造方法进行++操作。 

 

如果更改位置:

public class MyTest6 {
    public static void main(String[] args) {
        Singleton instance = Singleton.getInstance();
        System.out.println("counter1-->"+ instance.counter1);
        System.out.println("counter2-->"+ instance.counter2);
    }
}
class Singleton{
    public static int counter1;
   private static Singleton singleton = new Singleton();

   private Singleton(){
       counter1++;
       counter2++;
       System.out.println(counter1);
       System.out.println(counter2);
   }
    public static int counter2 = 0;

    public static Singleton getInstance(){
       return singleton;
   }
}

 

 

 解析:

按照从上到下的顺序进行初始化。

类主动使用时候,先准备,给类的静态变量赋初始值。

此时: 

   counter1 初始值 0 

   singleton 初始值 null

   counter2 初始值 0

接着调用静态方法 getInstance时候,赋初始值。

sigleton 会指向一个实例,然后执行私有构造方法。

然后执行到 public static int counter2 = 0时候,显示赋值0了。

 

总结:

 先准备

 再初始化: 根据类里面代码的顺序去执行的.真正的赋值(准备为其提供初始值,要不谈不上做++操作)

 

 

 

 

 画个图:

 

 

 关于类的实例化:

  为对象分配内存,即为new对象,在堆上面。

   为实例变量赋默认值、为实例变量赋正确的初始值都跟静态变量似的了。赋予默认值之后,再去赋予开发者指定的值。

 

类的加载:

  •   类的加载的最终产品是位于内充中的Class对象
  •   Class对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口

Class是反射的入口。像一面镜子一样。

 

有两种类型的类加载器:

  1.Java虚拟机自带的加载器

  • 根类加载器(BootStrap)
  • 扩展类加载器(Extension)
  • 系统(应用)类加载器(System)

2.用户自定义的类加载器

  • java.lang.ClassLoader的子类
  • 用户可以定制类的加载方式

 

类的加载: 

 类加载器并不需要等到某个类被“首次主动使用”时候再加载它

 注:

  •   JVM规范允许类加载器在预料某个类将要被使用时就预先加载它。如果在预先加载的过程中遇到了.class文件确实或者存在错误,类加载器必须在程序首次主动使用该类时候才报告错误(LinkageaError错误)
  •   如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误

 

类的验证:

  类被加载后,就进入连接阶段。连接就是将已经读入到内存中的类的二进制数据合并到虚拟机的运行时的环境中去。

  

类的验证的内容:

  •   类文件的结构检查
  •   语义检查
  •   字节码验证
  •   二进制兼容性的验证

在准备阶段:

  

 

 

 

 初始化阶段:

 

 

 

 类的初始化步骤:

  •  假如这个类还没有被加载和连接,那就先进行加载和连接
  •  假如类存在直接父类,并且这个父类还没有被初始化,那就先初始直接父类
  •  假如类中存在初始化语句,那就依次执行这些初始化语句 

 

 

只有当程序访问的静态变量或静态方法确实在当前类或当前接口定义时,才可以认为是对类或接口的主动使用。

调用ClassLoader类的loadClass方法加载一个类,并不是对类的主动使用,不会导致类的初始化。

 

 

 

 

除了以上虚拟机自带的加载器外,用户还可以定制自己的类加载器。Java提供了抽象类java.lang.ClassLoader,所有用户自定义的类加载器都应该继承ClassLoader类

 

 

引申看下这个例子:

 public class MyTest {
    public static void main(String[] args) {
        System.out.println(MyChild.b);
    }
}

interface MyParent{
  public static int a = 5;
}
interface MyChild extends MyParent{
 public static final int b = 8;
}

分析:

MyTest类有main函数。会主动使用,先去加载。

接口和类其实是不同的,如下:

加载层面: 

如果是类的话,MyChild肯定会被加载。如果是接口的话,不会被加载。

如果把b 修改为 Random(运行期才知道的值)。会将Parend 和 Child都加载. 很重要的一点是变量是编译器的还是运行期才能确定的

如果 parent和child都是final,test用到的常量会放入自己的常量池中,则不会对parent和child进行加载了。

如果把接口换做class,则存在加载,不加载的话必须是final的!

 

总结出了final关键字的区别小结:

  •   final修饰的变量,决定当前类是否加载。(static修饰的,不会这样)
  •   implement 实现的接口,不会加载

final修饰后,哪个类去主动调用就将这个常量放入到自己类的常量池里面。

 

Remember:

 block 优先 构造函数执行,每次都执行。

 

证明初始化一个类时候,不会初始化他的接口:

public class MyTest5 {
    public static void main(String[] args) {
        System.out.println(MyChild5.b);
    }
}

interface MyParent5 {
    public static Thread thread = new Thread(){
        {
            System.out.println("MyParent5 Thread ==========");
        }
    };
}

interface MyChild5 extends MyParent5 {
    public static int b = 6;
}
class C{
    {
        System.out.println("hello c{block}");
    }
    public C(){
        System.out.println("hello c(construct)");
    }
}

 

 

 如果将父子的interface 改成class 则会初始化父类

 当一个类被初始化时候,他所实现的类是不会被初始化的。

 

继续看下面例子:

public class MyTest5 {
    public static void main(String[] args) {
        System.out.println(MyChild5.b);
    }
}


interface MyGrandPa{
    public static Thread thread = new Thread(){
        {
            System.out.println("MyGrandPa Thread ==========");
        }
    };
}


interface MyParent5 extends MyGrandPa{
    public static Thread thread = new Thread(){
        {
            System.out.println("MyParent5 Thread ==========");
        }
    };
}

interface MyChild5 extends MyParent5 {
    public static int b = 6;
}
class C{
    {
        System.out.println("hello c{block}");
    }
    public C(){
        System.out.println("hello c(construct)");
    }
}

 

 

 

总结:

  1.  先看是否是finanl修饰,是的话,就不用加载别的类。前提是编译器的。
  2. 再看interface否。

 

 

类加载器的双亲委派机制:

  在双亲委派机制中,各个加载器按照父子关系形成了树形结构,除了根类加载器之外,其余的类加载器都有且只有一个父类加载器。

 

 

 

 

 

 

 

如果有一个类加载器能够成功加载Test类,那么这个类加载器被称为定义类加载器,所有能够成功返回Class对象引用的类加载器(包括定义类加载器)都被称为初始化类加载器。(了解即可)

public class MyTest7 {
    public static void main(String[] args) throws ClassNotFoundException {
        Class<?> clazz = Class.forName("java.lang.String");
        System.out.println(clazz.getClassLoader());

        Class<?> mClazz = Class.forName("com.jvm.t1.M");
        System.out.println(mClazz.getClassLoader());

    }
}
//位于工程的classPath目录地址下
class M{

}

 

 

 

 

 

如下例子:

package com.jvm.t1;

public class MyTest9 {
    static {
        System.out.println("MyTest9 static block");
    }

    public static void main(String[] args) {
        System.out.println(Child.b);
    }
}

class Parent{
    static int a = 3;
    static {
        System.out.println("parent static block");
    }
}

class Child extends Parent{
    static int b = 4;
    static {
        System.out.println("chile static block");
    }
}

 

 便于查看加载过程清晰:

 

输出结果:

 

 

 

 

看下面的例子:

public class MyTest10 {
 static {
     System.out.println("MyTest10 static block");
 }

    public static void main(String[] args) {
     //声明类型的使用,并不是主动使用
        Parent2 parent2;
        System.out.println("-------");
        parent2 = new Parent2();
        System.out.println("---------");
        System.out.println(parent2.a);
        System.out.println("---------");
        System.out.println(Child2.b);

    }
}
class Parent2{
  static int a = 3;
  static {
      System.out.println("Parent2 static block");
  }
}

class Child2 extends Parent2{
    static int b = 4;
    static {
        System.out.println("Child2 static block");
    }
}

 

 

 使用child时候,parent已经被初始化了,只会初始化一次。

总结:

初始化一次就OK了。 

 

看下面例子:

class Parent3{
    static int a = 3;
    static {
        System.out.println("Parent3 static block");
   }
   static void doSomeThing(){
        System.out.println("do something");
    }
}
class Child3 extends Parent3{
    static {
        System.out.println("Child3 static block");
    }
}

public class MyTest11 {
    public static void main(String[] args) {
        //访问父类的。调用父类的Parent的(主动使用)
        System.out.println(Child3.a);
        //访问的父类的。调用父类的Parent的(主动使用)
        Child3.doSomeThing();
    }
}

 

 

 

总结: 

  • 虽然名字是Child3 但是没有对其主动使用。
  • 如果使用子类去访问父类定义的变量、方法,本质上都表示对于父类的主动使用!

 

 

 

 

看下面例子:

class CL{
    static {
        System.out.println("static block class CL");
    }
}

public class MyTest12 {
    public static void main(String[] args) throws ClassNotFoundException {
        //系统类加载器(应用类加载器)
        ClassLoader classLoader = ClassLoader.getSystemClassLoader();
        //指定加载的类
        //这个不会导致类的初始
        Class<?> clazz = classLoader.loadClass("com.jvm.t1.CL");
        System.out.println(clazz);
        System.out.println("-------");
        //类的初始化,反射导致类的初始化
        clazz = Class.forName("com.jvm.t1.CL");
        System.out.println(clazz);
    }
}

 

 

 

总结:

  • 调用classLoader.loadClass 不是对类的主动使用,不会导致初始化
  • 反射是对类的主动使用




关于双亲委派机制:
 

public class MyTest13 {
    public static void main(String[] args) {
        ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
        System.out.println(systemClassLoader);
        while (null !=  systemClassLoader){
            systemClassLoader = systemClassLoader.getParent();
            System.out.println(systemClassLoader);
        }

    }
}

 

 

 结论:

 在HotSpot中,BootStrap ClassLoader使用null表示的.(启动类加载器)

  

看下面例子:

public class MyTest14 {
    public static void main(String[] args) {
        //获取上下文的类加载器。线程创建者提供的。(有默认值的)
        ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
        System.out.println(contextClassLoader);

    }
}

 

 

类型是APPClassLoader,加载应用的类加载器(系统类加载器)。

 

看下面的例子:

public class MyTest14 {
    public static void main(String[] args) throws IOException {
        //获取上下文的类加载器。线程创建者提供的。(有默认值的)
        ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
        //存在磁盘上的字节码(磁盘上的目录)
        String resourceName = "com/jvm/t1/MyTest13.class";
        //给定名字的所有资源(图片、音频等)
        Enumeration<URL> resources = contextClassLoader.getResources(resourceName);
       while (resources.hasMoreElements()){
           URL url = resources.nextElement();
           System.out.println(url);
       }

    }
}

 

 

 

 

获取ClassLoader的途径:

 

 

 我们自己定义的类,APPClassLoader:

public class MyTest14 {
    public static void main(String[] args) throws IOException {
        Class<MyTest14> myTest14Class = MyTest14.class;
        System.out.println(myTest14Class.getClassLoader());
    }
}

 

 

  

 

public class MyTest14 {
    public static void main(String[] args) throws IOException {

        Class<String> stringClass = String.class;
        System.out.println(stringClass.getClassLoader());
    }
}

 

 

 String 这个类位于rt.jar

 

用户自定义的类加载器都直接或间接的从ClassLoader类继承下来。

数组类的Class对象并不是由类加载器创建的,运行时由于Java虚拟机自动创建的。只有数组如此

 

public class MyTest15 {
    public static void main(String[] args) {
        String[] strings = new String[2];
        System.out.println(strings.getClass().getClassLoader());
        System.out.println("--------------");
        MyTest15[] myTest15s = new MyTest15[12];
        System.out.println(myTest15s.getClass().getClassLoader());
        System.out.println("--------------");
        int[] ins = new int[2];
        System.out.println(ins.getClass().getClassLoader());
    }
}

 

 

 

 

总结:

 

 

  • 根据里面的每个元素的类型定义的!String、MyTest15。 
  • 虽然获取到了数组的类加载器,但是数组对应的Class对象并不是ClassLoader加载的,是JVM动态创建的。
  • 原生类型,没有加载器。

 

 自己定义类加载器,看下面例子:

 

public class MyTest16 extends ClassLoader {
    private String classLoaderName = "";
    private String fileExtension = ".class";

    public MyTest16(String classLoaderName) {
        super(); // 将系统类加载器当做该类加载器的父类加载器
        this.classLoaderName = classLoaderName;
    }

    public MyTest16(ClassLoader parent, String classLoaderName) {
        super(parent);  //显示指定该类的加载器的父类加载器
        this.classLoaderName = classLoaderName;
    }


    private byte[] loadClassData(String name) {
        InputStream is = null;
        byte[] data = null;
        ByteArrayOutputStream baos = null;
        try {
            //注意win和linux
            this.classLoaderName = this.classLoaderName.replace(".", "/");
            is = new FileInputStream(new File(name + this.fileExtension));
            baos = new ByteArrayOutputStream();
            int ch ;
            while (-1 != (ch = is.read())) {
                baos.write(ch);
            }
            // 字节数组输出流转换成字节数组
            data = baos.toByteArray();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                is.close();
                baos.close();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return data;
    }

    @Override
    protected Class<?> findClass(String className) throws ClassNotFoundException {
        byte[] data = this.loadClassData(className);
        //返回Class对象
        return this.defineClass(className, data, 0 , data.length);
    }

    public static void test(ClassLoader classLoader) throws ClassNotFoundException, IllegalAccessException, InstantiationException {
        //内部底层的api已经被我们重写了
        Class<?> clazz = classLoader.loadClass("com.jvm.t1.MyTest15");
        Object object = clazz.newInstance();
        System.out.println(object);
    }

    @Override
    public String toString() {
        return "[" + this.classLoaderName + "]";
    }

    public static void main(String[] args) throws IllegalAccessException, InstantiationException, ClassNotFoundException {
        MyTest16 loader1 = new MyTest16("loader1");
        test(loader1);
    }
}

 

 

 

 

其实此时我们定义的 findClass是没有被调用的!以为双亲委派机制,让父类去加载了!

 

看下面例子:

public class MyTest16 extends ClassLoader {
    private String classLoaderName = "";
    private String fileExtension = ".class";
    private String path;

    public MyTest16(String classLoaderName) {
        super(); // 将系统类加载器当做该类加载器的父类加载器
        this.classLoaderName = classLoaderName;
    }

    public void setPath(String path){
        this.path = path;
    }

    public MyTest16(ClassLoader parent, String classLoaderName) {
        super(parent);  //显示指定该类的加载器的父类加载器
        this.classLoaderName = classLoaderName;
    }


    private byte[] loadClassData(String className) {
        InputStream is = null;
        byte[] data = null;
        ByteArrayOutputStream baos = null;

        className.replace(",","/");


        try {
            //注意win和linux
            this.classLoaderName = this.classLoaderName.replace(".", "/");
            //指定磁盘全路径
            is = new FileInputStream(this.path + new File(className + this.fileExtension));
            baos = new ByteArrayOutputStream();
            int ch ;
            while (-1 != (ch = is.read())) {
                baos.write(ch);
            }
            // 字节数组输出流转换成字节数组
            data = baos.toByteArray();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                is.close();
                baos.close();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return data;
    }

    @Override
    protected Class<?> findClass(String className) throws ClassNotFoundException {

        System.out.println("findClass invoked:" + className);
        System.out.println("class loader name" + this.classLoaderName);

        byte[] data = this.loadClassData(className);
        //返回Class对象
        return this.defineClass(className, data, 0 , data.length);
    }

    @Override
    public String toString() {
        return "[" + this.classLoaderName + "]";
    }

    public static void main(String[] args) throws IllegalAccessException, InstantiationException, ClassNotFoundException {
        // 创建自定义类加载器 名字“loader1” 父类加载器是系统类加载器
        MyTest16 loader1 = new MyTest16("loader1");
        //此路径为classPath,故 findClass方法不会被调用执行! 如果换个路径,不是classPath就会去执行了!
        loader1.setPath("D:\\eclipse_pj\\dianshang\\jvmTest\\out\\production\\jvmTest\\");
        Class<?> clazz = loader1.loadClass("com.jvm.t1.MyTest15");
        System.out.println("class:"+ clazz.hashCode());
        Object object = clazz.newInstance();
        System.out.println(object);

    }
}

 

 委托给父类,父类去classPath目录下面找,找到了加载之。

 

关于命名空间:

  • 每个类加载器都有自己的命名空间,命名空间由该加载器及所有父加载器所加载的类组成
  • 同一个命名 空间中,不会出现类的完整名字(包括类的包名)相同的两个类
  • 在不同的命名空间中,有可能会出现类的完整名字(包括类的包名)相同的两个类 

 

关于类的卸载:

  •  当MySample类被加载、连接和初始化后,他的声明周期就开始了。当代表MySample类的Class对象不再被引用,即不可触及时,Class对象就会结束声明周期,MySample类在方法区内的数据也会被卸载,从而结束Sample类的生命周期。
  •  一个类何时结束生命周期,取决于代表他的Class对象何时结束生命周期。
  • 由用户自定义的类加载器所加载的类是可以被卸载的。  

 

 

 

  加载  <----> 卸载

 

 

 看下面的例子:

public class MySample {
    MySample(){
        System.out.println("MySample is loaded by"+ this.getClass().getClassLoader());
        MyCat myCat = new MyCat();

    }
}
public class MyCat {
    public MyCat() {
        System.out.println("MyCat is loaded by" + this.getClass().getClassLoader());
    }
}
public class MyTest17 {
    public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {
        MyTest16 loader1 = new MyTest16("loader1");
        //要加载的类
        Class<?> clazz = loader1.loadClass("com.jvm.t1.MySample");
        System.out.println("clazz"+ clazz.hashCode());

        //如果注释掉改行,那么并不会实例化MySample对象,即MySample构造方法不会被调用
        // 因此不会实例化MyCat对象,即没有对MyCat进行主动使用,这里就不会加载MyCat class
        Object object = clazz.newInstance();// new instance 没有任何参数。调用无参构造方法

    }
}

 

 

 关于命名空间的说明: 

  1.   子加载器加载的类,能够访问父加载器加载的类。  
  2.   父加载器加载的类,不能访问子加载器加载的类。

 

public class Test3 {
    public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException, NoSuchMethodException, InvocationTargetException {
        MyTest16 loader1 = new MyTest16("loader1`");
        MyTest16 loader2 = new MyTest16("loader2`");
        
        loader1.setPath("/User/test/");
        loader1.setPath("/User/test/");

        //加载相同的类。(都委托为appClassLoader了)
        Class<?> clazz1 = loader1.loadClass("com.jvm.test.Test");
        //加载过了
        Class<?> clazz2 = loader2.loadClass("com.jvm.test.Test");

        // 都是app加载的,双亲委派
        System.out.println(clazz1 == clazz2);

        Object o1 = clazz1.newInstance();
        Object o2 = clazz2.newInstance();

        Method setMyPerson = clazz1.getMethod("setMyPerson", Object.class);
        //执行o1的方法,参数是o2
        setMyPerson.invoke(o1, o2);

    }
}

 

情况1.如果 class字节码在classPath,返回 true。 执行成功。(读者自行考虑,提示双亲委派)

情况2.如果 class字节码只在:"/User/test/" 。返回false。执行报错。

       原因

  •    命名空间。 两个loader不存在父子关系,是平行的。在jvm中存在两个命名空间。
  •    不同命名空间的类不可见,引用不到就报错。(子加载器的命名空间包含所有父加载器的命名空间,子可看到父类加载的所有类。)

 

 

双亲委派的好处:

  1.    可以确保Java核心库的类型安全,所有的Java应用都至少会引用java.lang.Object类,也就是说在运行期,java.lang.Object这个类会被加载到Java虚拟机中。
  2.    如果这个加载过程是由Java应用自己的类加载器所完成的,那么很可能就会在JVM汇总存在多个版本的java.lang.Object类。而且这些类之间是不兼容,相互不可见的(命名空间)。
  3.    借助双亲委派机制,java核心类库中的类的加载工作都是启动类加载器统一完成的。确保了Java应用所使用的都是同一个版本的Java核心类库,他们之间是相互兼容的。Java核心类库不会被自定义的替代。启动类去加载之。
  4.    不同的类加载器可以为相同名称(binary name)的类创建额外的命名空间,相同名称的类可以并存在Java虚拟机中,只需要不用的类加载器(包括没有父子关系、不同类加载器)来加载他们即可。不同类加载器所加载的类是不兼容的。这就相当于在Java虚拟机内部创建了一个又一个相互隔离的       Java类空间,这类技术在很多框架中都得到了实际应用。

 

知识总结: 

  1. 关于扩展类加载器:需要做成jar包,再放到指定目录下。
  2. 在运行期,一个Java类是由该类的完全限定名(binary name, 二进制名)和用于加载该类的定义列类加载器(defing loader)所共同决定的。如果同样名字(即相同的完全限定名)的类是由两个不同的加载器所加载,那么这些类就是不同的。即便 .class文件的字节码完全一样,并且从相同的位置加载亦如此。
  3. 在Oracle的hotSopt实现中,系统属性sun.boot.class.path如果修改错了,则运行会报错,提示: Error occurred during Initialization of VM
  4. 内建于JVM中的启动类加载器会加载java.lang.ClassLoader以及其他的Java平台类,当JVM启动时候,一块特殊的机器码会运行,他会扩展类加载器与系统类加载器,这块特殊的机器码叫做启动类加载器(BootStrap)。
  5. 启动类加载器并不是Java类,而其他的加载器都是Java类。启动类加载器是特定于平台的机器指令,它负责开启整个加载过程。
  6. 所有类加载器(除启动类加载器)都被实现为Java类。不过,总归要有一个组件来加载第一个Java类加载器,从而让整个加载过程能够顺利进行下去,加载第一个纯Java类加载器就是启动类加载器的职责。
  7. 启动类加载器还会负责加载供JRE正常运行所需要的基本组件,这包括java.util与java.lang包中的类等等。

 

简单看下:

public class test4 {
    public static void main(String[] args) {
        System.out.println(ClassLoader.class.getClassLoader());
        //扩展类
        System.out.println(Launcher.class.getClassLoader());
    }
}

 

 

 

可以自己做系统类加载器。略。需要控制台指令显示指定

通过改变属性,提示:

System.getProperty("java.system.class.loader")

 

 

引申:

 getSystemClassLoader()

  •   返回用于委托的系统类加载器,
  •   创建的ClassLoader默认的爸爸(也是用于启动应用的类加载器)。
  •   创建类加载器,然后设置为调用这个方法的线程的上下文类加载器。(Contex Class Loader)。应用框架,服务器大量使用的!
  •   默认的系统类加载器,与此类实现相关的实例。 
  •   java.system.class.loader所指定的类,是被默认的系统类加载器加载。必须要定义public的构造方法,传递自定义类加载器的爸爸。

 

OpenJDK是JDK开源版本。

 

解析Class.forName:

 其实:Class.forName("Foo") 等价于 Class.forName("Foo",true, this.getClass().getClassLoader() ) 

 

关于线程上下文的类加载器: Thread.currentThread().setContextClassLoader(sys)

作用就是改变双亲委派模型在某些场景下不适用的情况。

看下面例子:

public class MyTest24 {
    public static void main(String[] args) {
        System.out.println(Thread.currentThread().getContextClassLoader());
        System.out.println(Thread.class.getClassLoader());  // 路径位置导致的
    }
}

 

 

 

当前类加载器(Current ClassLoader)

每个类都会使用自己的类加载器(即加载自身的类加载器)去加载其它类(指的是所依赖的类):

  如果ClassX引用了ClassY,那么ClassX的类加载器就会去加载ClassY(前提是ClassY尚未被加载)

  

线程上下文类加载器:

  •  线程上下文类加载器是从JDK1.2开始引入的,类Thread中的getContextClassLoader  与 setContextClassLoader(ClassLoader cl) 分别用来获取和设置上下文类加载器
  • 如果没有通过setContextClassLoader进行设置的话。线程将继承其父线程的上下文类加载器。
  • Java应用运行时的初始线程的上下文类加载器是系统类加载器。在线程中运行的代码可以通过该类加载器来加载类与资源。

 线程上下文类加载器的重要性:

 应用场景: 

 SPI(Service Provider Interface)

 父ClassLoader可以使用当前线程Thread.currentThread().getContexClassLoader() 所指定的ClassLoader加载的类,这就改变了父ClassLoader不能使用子ClassLoader或是其他没有直接父子关系的ClassLoader加载的类的情况。

 线程上下文类加载器就是当前线程的Current ClassLoader

在双亲委派模型下,类加载是由下至上的,即下层的类加载器会委托上层进行加载。但是对于SPI来说,有些接口是Java类核心库所提供的,而Java核心库是由启动类加载器来加载的,而这些接口的实现却来自于不同的jar包(厂商提供。

Java的启动类加载器是不会加  载其他来源你的Jar包 ,这样的传统的双亲委派模型就无法满足SPI的要求。而通过给当前线程设置上下文类加载器,就可以由设置的上下文类加载器来实现对于接口实现类的加载。

 总结:接口是启动类加载器加载的, 实现类应用类加载器加载,通过给当前的线程设置上下文类加载器,实现对于接口实现类的加载,打破了双亲委派模型现在。(框架开发,底层开发会用到)

(JDK中没有对于JDBC的任何实现,除了传统的接口之外,具体实现都是由厂商趋势线的,比如MySQL。)

 

 

 

看下面代码:

public class MyTest25 implements Runnable {

    private Thread thread;

    public MyTest25(){
        thread = new Thread(this);
        thread.start();
    }

    @Override
    public void run() {
        // 获取到上下文类加载器
        ClassLoader classLoader = this.thread.getContextClassLoader();
        this.thread.setContextClassLoader(classLoader);
        System.out.println("Class:"+classLoader.getClass());
        System.out.println("Class:"+classLoader.getParent().getClass());
    }

    public static void main(String[] args) {
        MyTest25 myTest25 = new MyTest25();
    }
}

 

没有设置,所以线程将继承父线程的上下文类加载器。

 

 

线程上下文类加载器的一般使用模式(获取 - 使用 - 还原)

注意:如果一个类由A加载器加载,那么这个类的依赖也是由相同的类加载器加载的(如果该依赖之前没有被加载过的话)

ContextClassLoader的作用就是为了破坏Java的类加载委托机制  

当高层提供了统一的接口让底层去实现,同时又要在高层加载(或者实例化)低层的类时候,就必须要通过线程上下文类加载器来帮助高层的ClassLoader找到并加载该类

 

看下面例子:

public class MyTest26 {

    public static void main(String[] args) {

        //设置下
       // Thread.currentThread().setContextClassLoader(MyTest26.class.getClassLoader());

        ServiceLoader<Driver> loader = ServiceLoader.load(Driver.class);
        Iterator<Driver> iterator = loader.iterator();

        while (iterator.hasNext()){
            Driver driver = iterator.next();
            System.out.println("driver" + driver.getClass() + ", loader" + driver.getClass().getClassLoader() );
        }

        System.out.println("当前线程上下文类加载器:" + Thread.currentThread().getContextClassLoader());
        System.out.println("ServiceLoader的类加载器:" + ServiceLoader.class.getClassLoader());
    }
}

 

 

关于字节码:

 

对于能编译成class字节码的代码,class的规范,合法性保证好了就OK了。

对于Idea编译器,是非常熟悉class字节码了,可以随心所欲的反编译。

对于java代码:

public class MyTest1 {

    private int a = 1;

    public int getA() {
        return a;
    }

    public void setA(int a) {
        this.a = a;
    }
}

idea看字节码:

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//

package com.jvm.t1.t2;

public class MyTest1 {
    private int a = 1;

    public MyTest1() {
    }

    public int getA() {
        return this.a;
    }

    public void setA(int a) {
        this.a = a;
    }
}

通过反编译指令:

 

 看到三个方法:其中一个是默认的构造方法。

 

详细查看字节码信息:输入

javap -c com.jvm.t1.t2.MyTest1

Compiled from "MyTest1.java"
public class com.jvm.t1.t2.MyTest1 {
//构造方法
public com.jvm.t1.t2.MyTest1();
//下面都是助记符 Code:
0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: aload_0 5: iconst_1 6: putfield #2 // Field a:I 9: return public int getA(); Code: 0: aload_0 1: getfield #2 // Field a:I 4: ireturn public void setA(int); Code: 0: aload_0 1: iload_1 2: putfield #2 // Field a:I 5: return }

 

看下面指令:

javap -verbose com.jvm.t1.t2.MyTest1

Classfile /D:/eclipse_pj/dianshang/jvmTest/out/production/jvmTest/com/jvm/t1/t2/MyTest1.class
  Last modified 2019-10-20; size 473 bytes
  MD5 checksum c5b1387c6f6c79b14c1b6a5438da3b29
  Compiled from "MyTest1.java"
public class com.jvm.t1.t2.MyTest1
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER

// 常量池: 占据相当大的比重 Constant pool: #
1 = Methodref #4.#20 // java/lang/Object."<init>":()V #2 = Fieldref #3.#21 // com/jvm/t1/t2/MyTest1.a:I #3 = Class #22 // com/jvm/t1/t2/MyTest1 #4 = Class #23 // java/lang/Object #5 = Utf8 a #6 = Utf8 I #7 = Utf8 <init> #8 = Utf8 ()V #9 = Utf8 Code #10 = Utf8 LineNumberTable #11 = Utf8 LocalVariableTable #12 = Utf8 this #13 = Utf8 Lcom/jvm/t1/t2/MyTest1; #14 = Utf8 getA #15 = Utf8 ()I #16 = Utf8 setA #17 = Utf8 (I)V #18 = Utf8 SourceFile #19 = Utf8 MyTest1.java #20 = NameAndType #7:#8 // "<init>":()V #21 = NameAndType #5:#6 // a:I #22 = Utf8 com/jvm/t1/t2/MyTest1 #23 = Utf8 java/lang/Object
//方法的描述 {
public com.jvm.t1.t2.MyTest1(); descriptor: ()V flags: ACC_PUBLIC Code: stack=2, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: aload_0 5: iconst_1 6: putfield #2 // Field a:I 9: return LineNumberTable: line 3: 0 line 5: 4 LocalVariableTable: Start Length Slot Name Signature 0 10 0 this Lcom/jvm/t1/t2/MyTest1; public int getA(); descriptor: ()I flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: getfield #2 // Field a:I 4: ireturn LineNumberTable: line 8: 0 LocalVariableTable: Start Length Slot Name Signature 0 5 0 this Lcom/jvm/t1/t2/MyTest1; public void setA(int); descriptor: (I)V flags: ACC_PUBLIC Code: stack=2, locals=2, args_size=2 0: aload_0 1: iload_1 2: putfield #2 // Field a:I 5: return LineNumberTable: line 12: 0 line 13: 5 LocalVariableTable: Start Length Slot Name Signature 0 6 0 this Lcom/jvm/t1/t2/MyTest1; 0 6 1 a I } SourceFile: "MyTest1.java"

使用如上的这个命令分析字节码时候,将会分析该字节码文件的魔数,版本号,常量池,类信息,类的构造方法,类中的方法信息,类变量与成员变量等信息。

备注:

 魔数: 所有的.class字节码文件的前4个字节都是魔数,魔数值为固定值: 0xCAFEBABE。

 魔数之后的4个字节为版本信息,前两个字节表示minor version(次版本号),后两个字节表示major  version(主版本号)。

 

常量池(constant pool): 紧接着主板号之后就是常量池入口。一个Java类中定义的很多信息都是由常量池来维护和描述的。常量池在整个字节码文件中占的比重最大,里面的信息会被很多地方引用到。相当于把常量集中在一个地方,其他地方用到时候去引用之。通过Index找到常量池中特定的常量。可以将常量池看做是class文件的资源仓库。比如:Java类总定义的方法与变量信息,都是存储在常量池中。常量池中主要存储两类常量:字面量与符号引用量。

注意:常量池!里面存放的不一定都是常量。也有变量信息。

  • 字面量如文本字符串,Java中声明为final 的常量值等,而符号引用,比如说类和接口的全局限定名,字段的名称和描述符,方法的名称和描述符等。

常量池的总体结构: Java类所对应的常量池主要由常量池数量与常量池数组(常量表)这两部分共同组成。常量池数量紧跟在主版本后面,占据2个字节;常量池数组则紧跟在常量池数量之后。常量池数组和一般的数组不同的是,常量池数组中不同的元素的类型,结构都是不同的,长度当然也就不同;但是每一种元素的数都是一个u1类型,该字节是个标志位,占据1个字节。JVM在解析常量池时候,会根据这个u1类型来获取元素的具体类型。值得注意的是:常量池数组中元素的个数 = 常量池数 - 1 (其中0暂时不使用)。目的是满足某些常量池索引值的数据在特定情况下需要表达 【不引用任何一个常量池】的含义。根本原因在于,索引为0也是一个常量(保留常量)。只不过它不位于常量表中,这个常量就对应null值。所以常量池的索引从1而非从0开始。

如下,从1开始:

 

常量池中数据类型:

 

 

 

 在JVM规范中,每个变量/字段都有描述信息,描述信息主要的作用是描述字段的数据类型、方法的参数列表(包括数量、类型与顺序)与返回值。根据描述符规则,基本数据类型和代表无返回值的void类型都

用一个大写字符来表示,对象类型则使用字符L加对象的全限定名称来表示。为了压缩字节码文件的体积。对于基本数据类型,JVM都只使用一个大写字母来表示,如下所示:

B ---> byte   C --> char  D ---> doube F ---> float  I --> int  J --long S  --> short  Z --> boolean  V --> void

L --->对象类型 ,如: L java/lang/String

 

对于数组类型来说,每一个维度使用一个前置的 [ 来表示,如 int[ ]  被记录为 [I , String[][] 被记录为[[ Ljava/lang/String

用描述符描述方法时,按照先参数列表,后返回值的顺序来描述。参数列表按照参数的严格顺序放在一组之内,如方法:

get getName (int id, String name)描述为:

 

常量池里面存储的各种 index  和 信息

 

Java字节码整体结构:

 

 

 

 

 

 完整Java字节码接口例子:

 

 

 

Access_Flag访问标志

访问标志信息包括该Class文件是类还是接口,是否被定义成public,是否是abstract,如果是类,是否被声明称final。

 

 

 

 

 字段表集合:

  字段表用于描述类和接口中声明的变量。这里的字段包含了类级别变量(静态变量)以及实例变量(非静态变量),但是不包括方法内部声明的局部变量。

 

 

  

 

 

一个field_info包含的信息:

 

 

 

  方法表:

methods_count: u2

 

 

 

前三个字段和field_info一样

 

 

 方法中每个属性都是一个attribute_info结构

 

 

 

JVM预定义了部分attribute,但是编译器自己也可以实现自己的attribute写入class文件里,供运行使用

不同的attribute通过attribute_name_index来区分

 

 

 Code结构

Code attribute的作用是保存该方法的结构,如所对应的字节码

 

 

  • attribute_length 表示attribute所包含的字节数,不包含attribute_name_index 和 attribute_length字段
  • max_stack 表示这个方法运行的任何时刻所能达到的操作数栈的最大深度
  • max_locals表示方法执行期间创建的局部变量的数目,包含用来表示传入的参数的局部变量 
  • code_length表示该方法所包含的字节码的字节数以及具体的指令码
  • 具体字节码即是该方法被调用时,虚拟机所执行的字节码
  • exception_table,这里存放的是处理异常的信息
  • 每个exception_table表项由start_pc, end_pc, handler_pc, catch_type 组成
  • start_pc和end_pc表示在code数组中的从start_pc到end_pc处(包含start_pc, 不包含end_pc)的指令抛出的异常会由这个表项来处理。
  • handeler_pc 表示处理异常的代码的开始处,catch_type 表示会被处理的异常类型,它指向常量池里的一个异常类。当catch_type为0时,表示处理所有异常。
  • 方法中的每个属性都是一个attribute_info结构

 code attribute的作用是保存该方法的结构,如所对应的字节码

 

 

 

 推荐大家使用:  jclasslib 阅读字节码信息

 

 Java中,每一个方法都是可以访问this(表示对当前对象的引用),

 字节码角度,如果方法本身是个非静态(实例)的,this可以作为方法的第一个方法,可以隐式的传递进来。会使得每个实例方法都可以访问this。至少会有个局部变量,这个局部变量就是this。

 

对于某各类Test,中的静态方法 使用了synchronized 关键字,相当于给这个Test对应的Class对象加锁了。

 

关于this关键字: 

 

Java编译器在编译时候,把对this方法的访问,转变成了对普通参数的访问。在Java中,每一个非静态实例的方法的局部变量中,至少会存在一个指向当前对象的局部变量。即:

对于Java类中的每一个实例方法(非static方法),其中在编译后所生成的字节码当中,方法参数的数量总会比源代码汇总方法的参数多一个(this),它位于方法的第一个参数位置处;这样我们就可以在Java实例方法中使用this访问当前对象的属性以及其他方法。这个操作是在编译期间完成的,由javac编译器,在编译时候将对this的访问转化为对一个普通实例方法参数的访问,接下来在运行期间,由JVM在调用实例方法时,自动向实例方法传入该this参数。所以,在实例方法的局部变量表中,至少会有一个指向当前对象的局部变量。

 

 

关于异常处理:

 

 Code结构:

 attribute_length表示attribute锁包含的字节数,不包含attribute_name_index和attribute_length字段

 max_stack表示这个方法运行的任何时刻所能达到的操作数栈的最大深度

 max_locals表示方法执行期间所创建的局部变量的数目,包含用来表示传入的参数的局部变量

 code_lenght表示该方法所含的字节码的字节数以及具体的指令码

 具体字节码即是该方法被调用时,虚拟机所执行的字节码

 exception_table, 这里存放的是处理异常的消息

 每个exception_tabel 表项由start_pc, end_pc , handler_pc ,catch_type 组成

 start_pc 和 end_pc 表示在code 数组中的从start_pc都end_pc处(包含start_pc, 不包含end_pc)的指令抛出的异常会由这个表项来处理

 handler_pc表示处理异常的代码的开始处。catch_type 表示会被处理的异常类型,它指向常量池里的一个异常类。当catch_type为0时,表示处理所有的异常。

 

Java字节码对于异常的处理方式:

 1.  统一采用异常表的方式来对异常进行处理

 2. 老版本中,并不是使用遗产表的方式来对异常进行处理的,而是采用特定的指令方式(了解)

 3. 当异常处理存在finally语句块时,现代化的JVM采取的方式将finally语句块的字节码拼接到每一个catch块后面,换句话说,程序存在多少个catch块,就会在每一个catch块后面重复多少个finally语句块的字节码。

 

栈帧,是一种用于帮助虚拟机执行方法调用与方法执行的数据结构。

栈帧, 本身是一种数据结构,封装了风阀的局部变量表,动态链接信息,方法的返回地址操作数栈等信息。

Java中,对于不同的类之间的关系,编译期间,地址关系实际上是不知道的。什么时候知道?

  1. 类加载时候

  2. 真正调用时候,才知道目标方法地址。

基于以上两点,引申出了符号引用和直接引用。

有些符号引用是在类加载阶段或是第一次使用时就会转换为直接引用,这种转换叫做静态解析;另外一些符号引用则是在每次运行期转为直接引用,这种转换叫做动态链接,这体现为Java的多态性

比如父类因用户指向子类实现。

   Aninaml a = new Cat();
    a.run();
    a = new  Fish();
    a.run
    

编译时候,a都是Animal.  字节码角度,都是Animal

运行时候,每次运行期,都会进行一次直接引用的转换。

 

 

JVM 方法调用的字节码指令:

 1. invokeinterface:调用接口中的方法,实际上是在运行期决定的,决定到底调用实现该接口的那个对象的特定方法(一个接口,n个实现类)。

 2. invokestatic: 调用静态方法

 3.invokespecial: 调用自己的私有方法,构造方法(<init>) 以及父类的方法

 4. invokevirtual: 调用虚方法,运行期动态查找的过程。

 5. invokedynamic: 动态调用方法。

 

静态解析的四种情况:

  1. 静态方法

  2.父类方法

  3. 构造方法

 4. 私有方法(公有方法可以被重写或者复写,多态的可能。私有方法在加载时候就能够被确定了)

以上四种称之为: 非虚方法。他们是在类加载阶段就可以将符号引用转换为直接引用的。

public class MyTest5 {
     public void test(GrandPa grandPa){
         System.out.println("grandPa");
     }

    public void test(Father father){
        System.out.println("father");
    }

    public void test(Son son){
        System.out.println("son");
    }

    public static void main(String[] args) {
         //都是GrandPal类型的
        GrandPa father = new Father();
        GrandPa son = new Son();

        MyTest5 myTest5 = new MyTest5();

        myTest5.test(father);
        myTest5.test(son);
    }



}

class GrandPa{

}
class Father extends  GrandPa{

}
class Son extends Father{

 

以上代码 ,  father的静态类型是Grandpa,而father的实际类型(真正指向的类型)是Father  

 

变量本身的静态类型是不会被改变的, GrandPa father

结论:

 变量的静态类型是不会发生变化的,而变量的实际类型是可以发生变化的(多态的一种体现)。实际类型是在运行期方可确定。

 

以上,方法的重载,参数类型不一样。方法重载是一种纯粹的静态行为。

所以,当使用myTest5调用方法的时候, 是根据类型进行匹配。寻找类型是 GrandPa的。编译器就可以完全确定的。

 

public class MyTest6 {
    public static void main(String[] args) {
        Fruit apple = new Apple();
        Fruit orange = new Orange();

        apple.test();
        orange.test();

        apple = new Orange();
        apple.test();
    }
}

class Fruit{
    public void test(){
        System.out.println("fruit");
    }
}
class Apple extends Fruit{
    @Override
    public void test() {
        System.out.println("apple");
    }
}
class Orange extends Fruit{
    @Override
    public void test() {
        System.out.println("orange");
    }
}

 

 

引申:

Java中,new起到了三个作用:

 1. 在堆上开辟空间

 2. 执行构造方法

 3. 将构造方法执行后返回的堆上的此引用值返回

 

方法的动态分派:

 方法的动态分派涉及到一个重要概念:方法接收者

 invokevirtual字节码指令的多态查找流程

 方法重载和方法重写,我们可以得到这个方法重载是静态的,是编译器行为,方法重写是动态的,是运行期行为。

 

public class MyTest7 {
    public static void main(String[] args) {
        Animal animal = new Animal();
        Dog dog = new Dog();
        animal.test("hello");
        dog.test(new Date( ));
    }
}

class Animal{
    public void test(String str){
        System.out.println("animal str");
    }
    public void test(Date date){
        System.out.println("animal date");
    }
}

class Dog extends Animal{

    @Override
    public void test(String  str) {
        System.out.println("dog str");
    }

    @Override
    public void test(Date date) {
        System.out.println("dog date");
    }
}

 

 

 

针对于方法调用动态分派的过程,虚拟机会在类的方法区建立一个虚方法表的数据结构(virtual method table,简称 vtable)

 

现代JVM在执行Java代码的时候,通常会将解释执行与编译执行二者结合起来执行。

所谓解释执行:通过解释器读取字节码,遇到相应的指令就去执行该指令

所谓编译执行:通过及时编译器(Just In Time, JIT)将字节码转为本地机器码来执行,现代JVM会根据代码热点来生成相应的本地机器码。

 

基于栈的指令集合基于寄存器的指令集之间的关系:

 1. JVM执行指令时所采取的的方式是基于栈的指令集

 2. 基于栈的指令集的主要操作: 入栈、出栈

 3. 基于栈的指令集的优势在于他可以在不同平台间一直,而基于寄存器的指令集是与硬件架构密切关联的,无法做到可移植。

 4.  基于栈的指令集的缺点: 完成相同的操作,执行数量通常要比基于寄存器的指令集数量多 。基于栈的指令集是在内存中操作的,而基于寄存器的指令集是直接由CPU执行的,它是在高速缓冲区中进行的,速度要快很多。虽然虚拟机可以采用一些优化手段,但总体      来说,基于栈的指令集的执行速度要慢一些。

 

注意:

栈 配合 局部变量表使用,局部变量表的0位置是this 

 

对应动态代理,主要有一个类(proxy)和一个接口(InvocationHandler)去搞定。

接口:

public interface Subject {
    void request();
}

实现类:

public class RealSubject implements Subject {
    @Override
    public void request() {
        System.out.println("reslsubjct");
    }
}

代理类:

/**
 * 动态代理文件
 */
public class DynamicSubject implements InvocationHandler {

    private Object sub;

    public  DynamicSubject(Object obj){
        this.sub = obj;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        System.out.println("before calling"+ method);

        method.invoke(this.sub, args);

        System.out.println("after calling"+ method);

        return null;

    }
}

测试:

public class Client {
    public static void main(String[] args) {
        RealSubject realSubject = new RealSubject();
        DynamicSubject dynamicSubject = new DynamicSubject(realSubject);
        Class<?> clazz = realSubject.getClass();
        //获取 Class对象是为了,动态代理需要类加载器。
        Subject subject = (Subject) Proxy.newProxyInstance(clazz.getClassLoader(), clazz.getInterfaces(), dynamicSubject);
        subject.request();
        System.out.println(subject.getClass());
    }
}

程序运行期动态生成的:

 

首先创建代理类,然后创建代理类的实例对象。 

 

 

对象分为两部分内容: 

1, 对象本身拥有的那些数据(位于堆)

2, 对象所属的类型(元数据信息,MetaData) 所有实例对应一个Class对象。位于方法区(存储的一部分对象的类型数据信息)

 

方案一:

对象引用的是一个指向对象实例的指针,另外一个指针指向方法区中的类型数据

方案二:(HotSpot的方案)

对象引用的是对象本身,和一个指向方法区汇总的类型数据指针  (对象实例数据、方法区)

 

 两种方案的差别L

堆发生垃圾回收频率很高,对于垃圾回收算法来说,有几种会涉及到对象移动(压缩):为了保证区域连续的地方增大,移动之

 

 

方案一:对象一旦移动了,指针值会发生变化!随着每次垃圾回收会变化。

方案二:指针不会随之变化。

 

 JVM内存划分:

虚拟机栈

程序计数器

本地方法栈:主要用于处理本地方法

堆: JVM管理的最大一块内存空间

线程共享的区域,主要存储元信息。从JDK1.8开始,彻底废弃永久代。使用元空间(meta space)

运行时常量池(方法区的一部分): 方法区的一部分内容。编译后的字节码的符号引用等等。加载完后,放入到方法区的运行时常量池。

直接内存: Direct Memory。 与Java NIO密切相关,JVM通过堆上的DirectByteBuffer来直接操作内存。

 

现代几乎所有的垃圾收集器都是采用的分代收集算法,所以堆空间也基于这一点进行了相应的划分。

 

Java对象的创建:

 new

 反射

 克隆

 反序列化

 

new关键字创建对象的3个步骤:

 1, 在堆内存中创建出对象的实例

 2, 为对象成员变量赋初始值(指的是,实例变量,区别静态变量)

 3, 将对象的引用返回。 

 

虚拟机干的活儿: 检查指令的参数new指令创建一个对象,指令参数是不是能在常量池中定位成一个类的符号引用。查看这个类是不是已经加载、链接、初始化了。

指针碰撞: 前提是堆中的空间通过一个指针进行分割,一侧是已经被占用的空间,另一侧是未被占用的空间。

空闲列表:(前提是堆内存空间中已被使用与未被使用的空间交织在一起的。这时,虚拟机就需要通过一个列表来记录那些空间是可以用的,哪些空间是已被使用的,接下来找出可以容纳下新创建对象的且未被使用的空间,在此空间存放该对象,同时还要修改列表的记录)

 

一个对象包含三部分布局:

  1.对象的头, 

  2.实例数据(class中定义的成员变量) 

  3.对齐填充

 

永久代属于与堆连接的一个空间,对于永久代处理是比较麻烦的。

元空间,使用的操作系统的本地内存。可以不连续的。元空间里还有元空间虚拟机,管理元空间的内存的分配和回收情况。 初始大小21M,随着对于内存占用,会进行垃圾回收,甚至内存扩展,可以扩展到内存大小的最大值。

存放一个类的元数据信息,在框架中,用到运行期动态生成类的手段。动态创建出来的类,元信息放在元空间。

 

元空间参数: -XX:MaxMetaspaceSize=200M

在Java虚拟机(以下简称JVM)中,类包含其对应的元数据,比如类的层级信息,方法数据和方法信息(如字节码,栈和变量大小),运行时常量池,已确定的符号引用和虚方法表。

在过去(当自定义类加载器使用不普遍的时候,几乎不动态搭理),类几乎是“静态的”并且很少被卸载和回收,因此类也可以被看成“永久的”。另外由于类作为JVM实现的一部分,它们不由程序来创建,因为它们也被认为是“非堆”的内存。

在JDK8之前的HotSpot虚拟机中,类的这些“永久的”数据存放在一个叫做永久代的区域。永久代一段连续的内存空间,我们在JVM启动之前可以通过设置-XX:MaxPermSize的值来控制永久代的大小,32位机器默认的永久代的大小为64M,64位的机器则为85M。永久代的垃圾回收和老年代的垃圾回收是绑定的,一旦其中一个区域被占满,这两个区都要进行垃圾回收。但是有一个明显的问题,由于我们可以通过‑XX:MaxPermSize 设置永久代的大小,一旦类的元数据超过了设定的大小,程序就会耗尽内存,并出现内存溢出错误(OOM)。

备注:在JDK7之前的HotSpot虚拟机中,纳入字符串常量池的字符串被存储在永久代中,因此导致了一系列的性能问题和内存溢出错误。想要了解这些永久代移除这些字符串的信息,请访问这里查看。

随着Java8的到来,我们再也见不到永久代了。但是这并不意味着类的元数据信息也消失了。这些数据被移到了一个与堆不相连的本地内存区域,这个区域就是我们要提到的元空间。

这项改动是很有必要的,因为对永久代进行调优是很困难的。永久代中的元数据可能会随着每一次Full GC发生而进行移动。并且为永久代设置空间大小也是很难确定的,因为这其中有很多影响因素,比如类的总数,常量池的大小和方法数量等。

同时,HotSpot虚拟机的每种类型的垃圾回收器都需要特殊处理永久代中的元数据。将元数据从永久代剥离出来,不仅实现了对元空间的无缝管理,还可以简化Full GC以及对以后的并发隔离类元数据等方面进行优化。

 

  

移除永久代的影响

由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间。因此,我们就不会遇到永久代存在时的内存溢出错误,也不会出现泄漏的数据移到交换区这样的事情。最终用户可以为元空间设置一个可用空间最大值,如果不进行设置,JVM 会自动根据类的元数据大小动态增加元空间的容量。

注意:永久代的移除并不代表自定义的类加载器泄露问题就解决了。因此,你还必须监控你的内存消耗情况,因为一旦发生泄漏,会占用你的大量本地内存,并且还可能导致交换区交换更加糟糕。

元空间内存管理

元空间的内存管理由元空间虚拟机来完成。先前,对于类的元数据我们需要不同的垃圾回收器进行处理,现在只需要执行元空间虚拟机的 C++ 代码即可完成。在元空间中,类和其元数据的生命周期和其对应的类加载器是相同的。话句话说,只要类加载器存活,其加载的类的元数据也是存活的,因而不会被回收掉。

我们从行文到现在提到的元空间稍微有点不严谨。准确的来说,每一个类加载器的存储区域都称作一个元空间,所有的元空间合在一起就是我们一直说的元空间。当一个类加载器被垃圾回收器标记为不再存活,其对应的元空间会被回收。在元空间的回收过程中没有重定位和压缩等操作。但是元空间内的元数据会进行扫描来确定 Java 引用。

元空间虚拟机负责元空间的分配,其采用的形式为组块分配。组块的大小因类加载器的类型而异。在元空间虚拟机中存在一个全局的空闲组块列表。当一个类加载器需要组块时,它就会从这个全局的组块列表中获取并维持一个自己的组块列表。当一个类加载器不再存活,那么其持有的组块将会被释放,并返回给全局组块列表。类加载器持有的组块又会被分成多个块,每一个块存储一个单元的元信息。组块中的块是线性分配(指针碰撞分配形式)。组块分配自内存映射区域。这些全局的虚拟内存映射区域以链表形式连接,一旦某个虚拟内存映射区域清空,这部分内存就会返回给操作系统。

Java永久代去哪儿了

上图展示的是虚拟内存映射区域如何进行元组块的分配。类加载器 1 和 3 表明使用了反射或者为匿名类加载器,他们使用了特定大小组块。 而类加载器 2 和 4 根据其内部条目的数量使用小型或者中型的组块。

 参考:https://www.infoq.cn/article/Java-PERMGEN-Removed

 

命令:jstat -gc 进程号  打印元空间信息

           jmap -clstats PID  打印类加载器数据

          jcmd PID GC.class_stats  诊断命令

 

jcmd 是从jdk1.7开始增加的命令

 1. jcmd pid VM.flag:查看JVM启动参数

 2. jcmd pid help: 列出当前运行的Java进行可以执行的操作

 3. jcmd pid help JFR.dump: 查看具体命令的选项

 4. jcmd pid PerfCounter.print: 查看JVM性能相关参数

 5. jcmd pid VM.uptime:查看JVM的启动时长

 6. jcmd pid GC.class_histogram 查看系统中类的统计信息

 7. jcmd pid Thread.print: 查看线程堆栈信息

 8. jcmd pid GC.heap_dump filename: 导出heap dump文件,导出的文件可以通过jvisualvm查看

 9. jcmd pid VM.system_properties: 查看JVM的属性信息

10. jcmd pid VM.version: 查看目标JVM进程的版本信息

11. jcmd pid VM.command_line:查看JVM启动的命令行参数信息

 

jstack: 可以查看或是导出Java应用程序中栈线程的堆栈信息

jmc: java  Mission Control

 

  

补充:

 针对于犯法调用动态分派的过程,虚拟机会在类的方法区建立一个虚方法表的数据结构(virtual method table, vtable)

 针对于invokeinterface指令来说,迅疾会建立一个叫接口方法表的数据结构(interface method table, itable) 

 

JVM运行时数据区:

 

 

 

 

程序计数器

本地方法栈

Java虚拟机栈(JVM Stack)

  •     Java虚拟机栈描述的是Java方法的执行模型: 每个方法执行的时候都会创建一个帧(Frame)栈用于存放局部变量表,操作栈,动态链接,方法出口等信息。一个方法的执行过程,就是这个方法对于帧栈的入栈出栈过程。
  •    线程隔离

堆 

  •   堆里存放的是对象的实例
  •   是Java虚拟机管理内存中最大的一块
  •   GC主要的工作区域,为了高效的GC,会把堆细分成更多的子区域
  •  线程共享

方法区:

  •   存方法每个Class的结构信息,包括常量池,字段描述,方法描述
  •  GC的非主要工作区域

  

看下面例子:

  

  public void method(){
        Object obj = new Object();
    }

 

生成了两部分内存区域:

 1.obj这个引用变量,因为是方法内的变量,放到JVM Stack里面

 2. 真正Object class的实例对象,放到Heap里面

 

上述的new语句一共消耗12个byte。JVM规定引用占4个byte(JVM Stack),而空对象是8个byte(在Heap)

方法结束后,对应Stack中的变量马上回收,但是Heap中的对象要等GC来回收

 

垃圾判断算法:

 引用计数算法(Reference Counting) 

    无法解决对象循环引用的问题

 跟搜索算法(Root Tracing) 

   GC Root: 

      在VM栈(帧中的本地变量)中的引用

      方法区中的引用变量

      JNI(即一般说的Native方法) 中的引用

 

方法区:

  Java虚拟机规范表示可以不要求虚拟机在这区实现GC,这区GC的“性价比”一般比较低

  在堆中,尤其是在新生代,常规应用进行一次GC一般可以回收70%~95%的空间,二方法区的GC效率远小于此

  当前的商业JVM都有实现方法区的GC,主要回收两部分内容:废弃常量与无用类

 

主要回收两部分内容: 废弃常量与无用类

 类回收需要满足如下3个条件:

  该类所有的实例都已经备GC, 也就是JVM中不存在该Class的任何实例

  加载该类的ClassLoader已经被GC

  该类对应的Java.lang.Class对象没有任何地方被引用,如不能在任何地方通过反射访问该类的方法

  在大量使用反射、动态代理、CGLib等字节码框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要JVM具备类卸载的支持以保证方法区不会溢出

 

 

标记清除算法:

  标记+清除两个过程效率不高吗,需要扫描所有对象。

  产生不连续碎片,空间碎片提多可能会导致后续使用中无法找到足够的连续内存而提前触发另一次的垃圾收集动作

 

复制算法: 

现在的商业虚拟机用的复制算法来回收新生代。Hotspot虚拟机默认eden和survivor的大小比例是8:1,也就是每次只有10%的内存是“浪费”的

在对象存活率高的时候,效率有所下降

如果不想浪费50%的空间,就需要有额外的空间进行分配担保用于应付半区内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

 

只需要扫描存活的对象,效率更高

不会产生碎片

需要浪费额外的内存作为复制区

适合生命周期端的对象,因为每次GC总能回收大部分的对象,复制的开销比较小

 

 

标记整理算法:

 没有内存碎片

 比标记清除耗费更多的时间

 

分代收集:

 一般是把Java对分为新生代和老年代,这样就可以根据年龄特点采用最适当的收集算法。新生代GC有大批对象死去,只有少量存活,那就选用复制算法只需要付出少量存活对象的复制成本就可以完成收集。

 

老年代:

  •   存放了经过一次或多次GC还存活的对象
  •   一般采用Mark-Sweep或者Mark-Compact算法进行GC
  •  有多种垃圾收集器可以选择。每种垃圾收集器可以看做一个GC算法的具体实现。可以根据具体应用的需求选择合适的垃圾收集器(吞吐量?响应时间?)

 

永久代:

 并不属于对(Heap)但是GC也会涉及到这个区域

 存放了每个Class的机构信息,包括常量池,字段描述,方法描述。与垃圾收集要收集的Java对象关系不大

 

内存结构:

 

 

 备注: 在Hotspot中本地方法栈和JVM方法栈是同一个,因此也可以用-Xss控制

 

GC要做的是将那些dead的对象所占用的内存回收掉

  •    HotSpot认为没有引用的对象是dead的
  •   HotSpot将引用分为四种: Strong、Soft、 Weak、 Phantom     

 

Strong即默认通过Object o = new Object() 这种方式赋值的引用

Soft、Weak、Phantom这三种原则都是集成Reference

 

在Full GC时会对Reference类型的引用进行特殊处理

  Soft: 内存不够时候,一定会被GC。长期不用也会被GC

  Weak: 一定会被GC,当Mark为Dead,会在ReferenceQueue中通知

  Phantom: 本来就没有引用,当从JVM heap中释放时会通知

 

 

GC的时机:

 在分代模型的基础上,GC从时机上分为两种: Scavenge GC 和 Full GC

   1.Scanvenge GC(Minor GC) :

            触发时机: 新对象生成时,Eden空间满了

            理论上Eden区大多数对象会在Scavenge GC回收,复制算法的执行效率会很高,Scavenge GC时间比较短

  2. Full GC(stop the world)

     对整个JVM进行整理,包括Young、Old 和Perm

    主要的触发时机: 1.Old满了  2.Perm满了 3.System.gc()

    效率很低,尽量减少Full GC

 

垃圾收集器的并发和并行:

 并行: 多个垃圾收集器的线程同时工作,但是用户线程处于等待状态

 并发: 收集器在工作的同事,可以允许用户线程工作。 并发不代表解决了GC停顿的问题,在关键的步骤还是要停顿。比如在垃圾收集器标记垃圾的时候。但是在清除垃圾的时候,用户线程可以和GC线程并发执行。


Serial收集器:

 单线程收集器,收集时会暂停所有工作线程(Stop The World)使用复制收集算法,虚拟机运行在Client模式时的默认新生代收集器。

 最早的收集器,单线程进行GC

 新生代和老年代都可以使用

 在新生代,采用复制算法;在老年代,采用标记整理算法

 因为是单线程GC,没有多线昵称切换的额外开销,简单使用

 Hotpot Client模式缺省的收集器

 

 

 ParNew收集器

  ParNew收集器就是Serial的多线程版本,除了使用多个收集线程外,其余行为包括算法,STW,对象分配规则,回收策略等都与Serial收集器一模一样

 对应的这种收集器是JVM运行在Server模式的默认新生代收集器,在单CPU的环境中,ParNew收集器并不会比Serial收集器有更好的效果。只有在多CPU的环境下,效率才会比Serial收集器高

 使用复制算法(因为针对新生代)

 通过-XX:ParallelGCThreads来控制GC线程数的多少。需要结合具体的CPU个数

 Server模式下新生代的缺省收集器

 

 Parallel Scavenge收集器

  Parallel Scavenge 收集器也是一个多线程的收集器,也是使用复制算法,但它的对象分配规则与回收策略都与ParNew收集器有所不同,它是以吞吐量最大化(即GC时间栈中运行时间最小)为目标的收集器实现,它允许较长时间的STW换取总吞吐量最大化

 

Serial Old收集器

 Serial Old是单线程收集器,使用标记整理算法,是老年代的收集器。

 老年代版本吞吐量优先收集器,使用多线程和标记整理,JVM1.6提供,在此以前,新生代使用了PS收集器的话,老年代除Serial Old别无选择。因为PS无法与CMS收集器配合工作。

 

Parallel Old

 Parallel Scavenge在老年代的实现

 在JVM1.6才出现 Parallel Old

 采用对线程,Mark-Compact算法

 更注重吞吐量

 Parallel Scavenge + Prallel Old = 高吞吐量,但GC停顿可能不理想

 

 

 

CMS(Concurrent Mak Sweep)

 cms是一种最短停顿时间为目标的收集器,使用CMS不能达到GC效率最高(总体GC时间最小),但它能尽可能降低GC时服务的停顿时间,CMS收集器使用的是标记清除算法

  追求最短停顿时间,非常适合Web应用

  只针对老年区,一般结合ParNew使用

  Concurrent,GC线程和用户线程并发工作(尽量并发)

  Mark-Sweep

  只有在多CPU环境下才有意义

  使用-XX:+UseConcMarkSweepGC打开

  

 缺点:

 以牺牲CPU资源的带劲来减少用户线程的停顿。当CPU个数少于4到时候,有可能对吞吐量影响非常大

 CMS在并发清理的过程中,用户线程还在跑。这是需要预留一部分空间给用户线程

 CMS用Mark-Sweep,会带来碎片问题。碎片过多时候会容易频繁触发Full GC。

 

GC乐基收集器的JVM参数定义

 

 

Java内存泄露常见原因:

 1.对象定义在错误范围(Wrong Scope)

 2.异常(Exception)处理不当

 3.集合数据管理不当

 

如果Foo实例对象的生命较长,会导致临时性内存泄露。(这里的names变量其实只有临时作用)

class Foo{
    private String[] names;
    
    public void doIt(int length){
        if (names == null || names.length < length){
            names = new String[length];
            
            populate(names);
            print(names);
        }
    }
}

 

JVM喜欢生命周期短的兑现,这样做已经足够高效。 (成员变量变成局部变量)

 class Foo{
    public void doIt(int length){
        String[] names = new String[length];
        populate(names);
        print(names);
    }
}

 

连接不释放:finally

 rs.close()  // 必须释放

 

集合数据管理不当:

当使用 Array-based的数据结构(ArrayList,HashMap等),尽量减少resize

  • 比如new ArrayList时,尽量估算size,在创建的时候把size确定
  • 减少resize可以避免没有必要的array copying, gc碎片等问题

如果一个List只需要顺序访问,不需要随机访问(Random Access),用LinkedList代替ArrayList

  • LinkedList本质是链表,不需要resize,但只适用于顺序访问

 

 

输出JVM垃圾回收,详尽的情况:-verbose:gc

 初始大小-Xms20M

 堆大小-Xmx20M

(相等,启动时候不会出现都抖动问题。)

 新生代大小 -Xmn10m

 垃圾回收详细信息:-XX:+PrintGCDetails 

 Edn:survor的比值 : -XX:SurvivorRatio=8

 

 

 

public class t {
    public static void main(String[] args) {
        int size = 1024 * 1024;
        //原生的数组 里面都是0
        byte[] myAlloc1 = new byte[2 * size];
        byte[] myAlloc2 = new byte[2 * size];
        byte[] myAlloc3 = new byte[2 * size];

        System.out.println("hello world");

    }
}

看出没有发生GC:

 

 

继续添加 数组:

byte[] myAlloc3 = new byte[2 * size];

发生了GC

 

 新生代的垃圾回收,怎么回收都不够呀。除了程序的,JVM启动时候也会有大量的对象

 Full GC 会导致Stop  the World 要避免之 Full GC后老年代有可能会变多哦

  

 

来个GC的

 

 PS: Parallel Scavenge收集器

9216K / 1024 = 9K 

这样的新生代使用的是9k  因为from to  有一个区域始终是闲置的

 

5646 - 624 = 5022K  执行完gc后,新生代释放的空间容量(包括晋升到老年代的)

5646-4728 = 918  执行完gc后,总的堆空间释放的容量 

5022 - 918 = 4104K  老年代使用的容量 

 

当新生代已经容纳下,待分配的对象时候,新创建的,直接诞生在老年代。

看下面的例子:

public class t {
    public static void main(String[] args) {
        int size = 1024 * 1024;
        //原生的数组 里面都是0
        byte[] myAlloc1 = new byte[2 * size];
        byte[] myAlloc2 = new byte[2 * size];
        byte[] myAlloc3 = new byte[2 * size];
        //这个数组直接在老年代分配!
        byte[] myAlloc4 = new byte[4 * size];

        System.out.println("hello world");

    }
}

没有发生Full GC:

 

 

直接在老年代分配对象!

不指定时候,默认使用:

PSYoungGen: Parallel Scavenge(新生代垃圾收集器)

ParOldGen: Parallel Old (老年代垃圾收集器)

 

命令行指令:

 java -XX:+PrintCommandLineFlags -version 

 

虚拟机参数: -XX:PretenureSizeThreshold=4194304  阈值  必须配合另外一个参数!-XX:UseSerialGC 否则不起作用

 

当新创建的对象大小超过这个,就直接诞生在老年代了

 

 

 

public class t {
    public static void main(String[] args) {
        int size = 1024 * 1024;
        //5M 超过阈值 
        byte[] myAlloc1 = new byte[5 * size];

    }
}

 

运行结果:

 

 

 JVM:

-Xms20M
-Xmx20M
-Xmn10m
-XX:+PrintGCDetails
-XX:SurvivorRatio=8
-XX:PretenureSizeThreshold=4194304
-XX:+UseSerialGC

 

结论:

 PretenureSizeThreadshold:设置超过多个时直接在老年代分配

 

 如果需要分配10M

public class t {
    public static void main(String[] args) {
        int size = 1024 * 1024;
        //10M 
        byte[] myAlloc1 = new byte[10 * size];

    }
}

 

 

 

 经过大量GC后,发现不能分配。放不下10M! 新生代+老年代一共才10M

 

设置阈值后,即便是新生代能容纳,一样会分配到老年代!

 

 

MaxTenuringThreshold作用: 在可以自动调节对象晋升(Promote)到老年代阈值的GC中,设置该阈值的最大值(再怎么自动调节也不会超过这个最大值)。 

--XX:MaxTenuringThreshold=5

改参数默认15,CMS中默认值为6,G1默认为15(在JVM中,该数值是由4个bit来表示的,所以最大值111 即15)

 

-XX:+PrintTenuringDistribution 打印的效果,比如打印年龄为1的对象的情况等等

 

经历了多次GC后,存活的对象会在From  Survivor 和 To Survivor之间来回存放,而这里的一个前提是这两个空间有足够的空间来存放数据,在GC算法中,会计算每个对象年龄的大小。如果达到了某个年龄后发现总大小已经大于了Survivor空间的50%,那么这时就需要调调整阈值,不能在继续等到默认的15次GC后才完成晋升。

因为这样会导致Survivor空间不足,所以需要调整阈值,让这些存活对象尽快完成晋升。

 

看下面的例子:

-Xms20M
-Xmx20M
-Xmn10m
-XX:+PrintGCDetails
-XX:SurvivorRatio=8
-XX:MaxTenuringThreshold=5
-XX:+PrintTenuringDistribution
-XX:+PrintCommandLineFlags

public class t {
    public static void main(String[] args) {
        int size = 1024 * 1024;
        //10M
        byte[] myAlloc1 = new byte[2 * size];
        byte[] myAlloc2 = new byte[2 * size];
        byte[] myAlloc3 = new byte[2 * size];
        byte[] myAlloc4 = new byte[2 * size];
        System.out.println("hello world");
    }
}

运行结果:

 

 

 -XX:+PrintCommandLineFlags : 图中箭头的启动参数信息

max 是设置的阈值  new Threshold 5 是自动调整的值

 

 

看下面的例子:

配置:

-verbose:gc
-Xmx20M
-Xmn50m
-XX:TargetSurvivorRatio=60
-XX:+PrintTenuringDistribution
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+UseConcMarkSweepGC
-XX:+UseParNewGC
-XX:MaxTenuringThreshold=3

 

 

解释:

-verbose:gc

-Xmx20M

-Xmn50m

-XX:targetSurvivorRatio=60 #在某一个survivor空间已经被存活的占据空间60,重新计算阈值。

-XX:+PrintTenuringDistribution #打印对象在survivor在对空间的年龄情况

-XX:+PrintGCDetails

-XX:+PrintGCDateStamps   #打印当前GC执行的的时间戳

-XX:+UserConcMarkSweepGC  #指定垃圾收集器CMS(CMS是用在老年代的)

-XX:UserParNewGC  #新生代ParNew

-XX:MaxTenuringThreshold=3

 

public class t {
    public static void main(String[] args) throws InterruptedException {
        byte[] byte1 = new byte[512 * 1024];
        byte[] byte2 = new byte[512 * 1024];

        myGC();
        Thread.sleep(1000);
        System.out.println("111");

        myGC();
        Thread.sleep(1000);
        System.out.println("222");

        myGC();
        Thread.sleep(1000);
        System.out.println("333");

        myGC();
        Thread.sleep(1000);
        System.out.println("444");

        byte[] byte3 = new byte[512 * 1024];
        byte[] byte4 = new byte[512 * 1024];
        byte[] byte5 = new byte[512 * 1024];

        myGC();
        Thread.sleep(100);
        System.out.println("555");

        myGC();
        Thread.sleep(100);
        System.out.println("666");

        System.out.println("hello world");
    }

    private static void myGC() {
        for (int i = 0; i < 40; i++) {
            byte[] byteArray = new byte[2014 * 1024];
        }
    }
}

 

 

 多次垃圾回收情况



 CMS:

枚举根节点:

 当执行系统停顿下来后,并不需要一个不漏的检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得知哪些地方存放着对象引用。在HotSpot的实现中,是使用一组成为OopMap的数据结构来达到这个目的的。

 

安全点:

 在OopMap的协助下,HotSpot可以快速且准确的完成GC Root枚举,但一个很现实的问题随之而来:可能导致引用关系变化,或者说OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外空间,这样GC的空间成本将会变得更     高亮

 实际上,HotSpot并没有为每条指令都生成OopMap,而只是在“特定的位置”记录了这些信息,这些位置成为安全点(SafePoint),即程序执行时并非在所有地方都停顿下来开始GC,只有在达到安全点时才能暂停。

 SafePoint 的选定既不能太少以至于让GC等待时间太长,也不能过于频繁以至于过饭增大运行时的负载。所以,安全点的选定基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的。因为每条指令执行的时间非常短暂,程序不太可能因为指令流长度太长而过   长的时间运行,“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等。所以具有这些功能的指令才会产生SafePoint.

  对于SafePoint,另一个需要考虑的问题时如何在GC发生时让所有线程(这里不包括执行JNI调用的线程)都“跑”到最近的安全点上再停顿下来:抢占式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)

  •     抢占式中断: 他不需线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。
  •     主动式中断: 当GC需要中断线程的时候,不直接对线程操作,仅仅简单的设置一个标志,各个线程执行时主动去轮训这个标志,发现中断标志为真时就自己中断挂起。轮训标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方

现在几乎没有虚拟机采用抢占式中断来暂停线程从而响应GC事件

 

安全区域:

 在使用SafePoint似乎已经完美地解决了如何进入GC的问题,但实际上情况却不一定。SafePoint机制保证了程序执行时候,在不太长时间内就会遇到可进入GC的SafePoint。但如果程序在“不执行”的时候呢?所谓程序不执行就是没有分配CPU时间,典型的例子就是出于Sleep状态或者Blocked状态,这时候线程无法响应JVM的中断请求,JVM也显然不太可能等地线程重新分配CPU时间,对于这种情况,就需要安全区域(Safe Regin)来解决了。

 在线程执行到Safe Regin中的代码时候,首先标识自己已经进入了Safe Regin,那样,当在这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程线程了。在线程要离开Safe Region时,他要检查系统是否已经完成了根节点枚举(或者是整个GC过程),如果完成了,那线程就继续执行,否则他就必须等待线程直到收到可以安全离开Safe Region的信号为止。

 

CMS收集器

 CMS收集器,以获取最短回收停顿时间为目标,多数应用于互联网站或者B/S系统的服务器上

 CMS是基于“标记-清除”算法实现的,整个工程分为四个步骤:

  1. 初始标记(CMS initial Mark)
  2. 并发标记  (CMS concurrent mark)
  3. 重新标记  (CMS remark)
  4. 并发清除  (CMS concurrent sweep)

其中,初始标记、重新标记这两个步骤仍然需要“Stop The World”

初始标记只是标记一下GC Roots能直接关联到的对象,速度很快

并发标记阶段就是进行GC Roots Tracing的过程 

重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍微长一些,但远比并发标记的时间短。

 

CMS收集器的运作步骤如下图所示,在整个过程中耗时最长的并发标记和并发清除过程收集器线程都是可以与用户线程一起工作,因此,从总体上看,CMS收集器的内存回收过程是与用户线程一起并发执行的

 

 

 优点:

 并发收集、低停顿,Oracle公司的一些官方文档中也称之为并发低停顿收集器(Concurrent Low Pause Collector)

 缺点:

  • CMS收集器对CPU资源非常敏感
  • CMS收集器无法处理浮动垃圾(FloatingGarbage),可能出现“Concurrent mode Failure”失败而导致另一次Full GC的产生。如果在应用中老年代增长不是太快,可以适当调高参数-XX:CMSInitiatingOccupancyFraction的值来提高触发百分比,以便降低内存回收次数从而获取更好的性能,要是CMS运行期间预留的内存无法满足程序需要时,虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所有说参数-XX:CMSInitiatingOccupancyFraction设置的太高很容易导致大量“Concurrent Mode Failure”失败,性能反而降低。
  • 收集结束时候,会有大量空间碎片产生,空间碎片过多时,将会给大对象分配带来很大麻烦,往往出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来支配当前对象,不得不提前进行一次Full GC。CMS收集器提供了一个 XX:+UseCMSCompactAtFullCollection开关参数(默认就是开启的),用于在CMS收集器顶不住时候要进行Full GC时开启内存碎片的合并整理过程,内存整理的过程是无法自拔的,空间碎片问题没有了,但停顿时间不得不边长。

  空间分配担保:

 在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。当大量对象在MinorGC后仍存活,就需要老年代进行空间分配担保,把Survivor无法容纳的对象直接进入老年代。如果老年代判断到剩余空间不足(根据以往每一次回收晋升到老年代对象容量的平均值作为经验值),则进行一次Full GC

 

CMS收集器收集步骤:

  1. Initial Mark
  2. Concurrent Mark
  3. Concurrent Preclean
  4. Concurrent Abortable Preclean
  5. Final Remark
  6. Concurrent Sweep
  7. Concurrent Reset

 

1. 是CMS两次Stop The World 事件中的其中一次,这个阶段的目标是:标记哪些直接被GC Root 引用或者被年轻代存活对象所引用的所有对象。

 

 2.在这个阶段Garbage Collector会遍历老年代,然后标记所有存活的对象,它会根据上个阶段找到的GC Root是遍历查找。并发标记阶段,他会与用户的应用程序并发运行。并不是老年代的所有的存活对象都会被标记,因为在标记期间用户的程序可能会改变一些引用。

 

 在上面的图中,与阶段1的图进行对比,就会发现有一个对象的引用地址发生了变化。

3. 这是个并发阶段,与应用的线程并发运行,并不会stop应用的线程。在并发运行的过程中,一些对象的引用可能会发生变化,但是这种情况发生时,JVM会将包含这个对象的区域(Card)标记为Dirty,也就是Card Marking

    在pre-clean阶段,那些能够从Dirty对象到达的对象也会被标记,这个标记做完之后,dirty card 标记就会被清除了

4. 这也是一个并发阶段,但是同样不会影响影响用户的应用线程,这个阶段是为了尽量承担 STW(stop-the-world)中最终标记阶段的工作。这个阶段持续时间依赖于很多的因素,由于这个阶段是在重复做很多相同的工作,直接满足一些条件(比如:重复迭代的次数、      完成的工作量或者时钟时间等)

5.这是第二个 STW 阶段,也是 CMS 中的最后一个,这个阶段的目标是标记所有老年代所有的存活对象,由于之前的阶段是并发执行的,gc 线程可能跟不上应用程序的变化,为了完成标记老年代所有存活对象的目标,STW 就非常有必要了。

  通常 CMS 的 Final Remark 阶段会在年轻代尽可能干净的时候运行,目的是为了减少连续 STW 发生的可能性(年轻代存活对象过多的话,也会导致老年代涉及的存活对象会很多)。这个阶段会比前面的几个阶段更复杂一些。

 

经历着五个阶段之后,老年代所有存活的对象都被标记过来,现在可以通过清除算法去清除哪些老年代不再使用的对象。

 

6.这里不需要STW,它是与用户的应用程序并发运行,这个阶段是:清除哪些不再使用的对象,回收他们的占用空间为将来使用

 

 

 7. 这个阶段也是并发执行的,它会重设CMS内部的数据机构,为下次的GC做准备

 

总结:

  • CMS通过将大量工作分散到并发处理阶段来减少STW时间,在这块做的非常优秀,但是CMS也有一些其他问题。
  • CMS收集器无法处理浮动垃圾(Floating garbage)可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生,可能引发串行Full GC
  • 空间碎片,导致无法分配大对象,CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开发参数(默认就是开启的),用于在CMS收集器顶不住要进行Full GC时开启内存碎片的合并整理过程,内存整理的过程是无法并发的,空间碎片问题没有了,但停顿时间不得不变长;
  • 对于堆比较大的应用,GC的时间难以估计。

 

 

看下面例子:

配置参数:

-verbose:gc
-Xmx20M
-Xms20M
-Xmn10m
-XX:+PrintGCDetails
-XX:SurvivorRatio=8
-XX:+UseConcMarkSweepGC

 

public class t {
    public static void main(String[] args) {
        int size = 1024 * 1024;
        byte[] myAlloc1 = new byte[4 * size];
        System.out.println("1111");
        byte[] myAlloc2 = new byte[4 * size];
        System.out.println("2222");
        byte[] myAlloc3 = new byte[4 * size];
        System.out.println("3333");
        byte[] myAlloc4 = new byte[4 * size];
        System.out.println("4444");
    }

}

 

 默认情况下,老年代的CMS 和新生代的 ParNew是成对出现的。

 可以看到CMS收集过程

 

 

吞吐量:

  吞吐量关注的是,在一个指定的时间内,最大化一个应用的工作量。 
  如下方式来衡量一个系统吞吐量的好坏:

    1.在一小时内同一个事务(或者任务、请求)完成的次数(tps)。 
    2.数据库一小时可以完成多少次查询;

    3.对于关注吞吐量的系统,卡顿是可以接受的,因为这个系统关注长时间的大量任务的执行能力,单次快速的响应并不值得考虑

 

响应能力:

响应能力指一个程序或者系统对请求的是否能够及时响应。 
比如: 
   一个桌面UI能多快的响应一个事件; 
   一个网站能够多快返回一个页面请求; 
   数据库能够多快返回查询的数据;

   对于这类对响应能力敏感的场景,长时间的停顿是无法接受的。

 

 

G1收集器是一个面向服务端的垃圾收集器,适用于多核处理器,大内存容量的服务端系统

它满足短时间GC停顿的同时达到一个较高的吞吐量

JDK7以上版本使用

 

G1收集器的设计目标

  • 与应用线程同时工作,几乎不需要stop-the-world(与CMS类似);
  • 整理剩余空间,不产生内存碎片;(CMS只能在full-GC时,用stop-the-world整理碎片内存)
  • GC停顿更加可控;
  • 不牺牲系统的吞吐量;
  • GC不要求额外的内存空间(CMS需要预留空间存储浮动垃圾);

 

G1的设计规划是要替换掉CMS

  •  G1在某些方面弥补了CMS的不足,比如,CMS使用的是mark-sweep算法,自然会产生碎片;然而G1基于copying算法,高效的剩余内存,而不需要管理内存碎片。
  •  另外,G1提供了更多手段,以达到对gc停顿的可控。

 

 

 堆:

 

 

 

下面看看G1:

 

 

 G1收集器堆结构

  •   heap被划分为一个个相等的不连续的内存区域(regions), 每个region都有一个分代的角色: eden、survivor、old
  •  对每个角色的数量并没有强制限定,也就是说对每种分代内存的大小,可以动态变化
  •  G1最大的特点就是高效的执行回收,优先去执行哪些大量对象可回收的区域(region)
  •  G1使用了gc停顿可预测的模型,来满足用户设定的gc停顿时间,根据用户设定的目标时间,G1会自动的选择哪些region需要清除,一次清除多少个region
  •  G1从多个region中复制存活的对象,然后集中放入一个region中,同时整理、清除内存(copying收集算法)

 

G1 VS CMS

  •  对比使用mark-sweep的CMS,G1使用的copying算法不会造成内存碎片
  •  对比Parallel Scavenge(基于copying)、parallel Old收集器(基于mark-compact-sweep), Parallel会对整个区域做整理导致gc停顿时间比较长,而G1只是特定的整理几个region
  • G1并非一个实时的收集器,与parallel Scavenge一样,对gc停顿时间的设置并不绝对生效,只是G1有较高的几率保证不超过设定的gc停顿时间。与之前的gc收集器对比,G1会根据用户设定的gc停顿时间,智能评估哪几region需要被回收可以满足用户的设定。

 

G1重要概念:

  •  分区(Region): G1采取了不同的策略来解决并行、串行和CMS收集器的碎片、暂停时间不可控等问题---G1将整个的堆分成相同大小的分区(region)每个分区都可能是年轻代也可能是老年代,但是在同一时刻只能属于某个代。
  • 年轻代、幸存区、老年代这些概念还存在,成为逻辑上的概念,这样方便复用之前分代框架的逻辑。
  • 在物理上不需要连续,则带来了额外的好处——有的分区内垃圾对象特别多,有的分区内垃圾对象很少,G1会优先回收垃圾对象特别多的分区,这样可以花费较少的时间来回收这些分区的垃圾,这也就是G1名字的由来,即首先收集垃圾最多的分区。
  • 新生代其实并不是适用于这种算法的,依然是在新生代满了的时候,对整个新生代进行回收—— 整个新生代中的对象,要么被回收、要么晋升到老年代,至于新生代也采取分区机制的原因,则是因为这样跟老年代的策略统一,方便调整代的大小。
  • G1还是一种带压缩的收集器,在回收老年代的分区时,是将存活的对象从一个分区拷贝到另一个可用分区,这个拷贝的过程就实现了局部的压缩。每个分区的大小从1M到32M不等,但是都是2的冥次方。
  • 收集集合(CSet):一组可被回收的分区的集合。在CSet中存活的数据会在GC过程中被移动到另一个可用分区,CSet中的分区可以来自Eden空间、survivor空间、或者老年代。CSet会占用不到整个堆空间的1%大小。

  • 已记忆集合(RSet)每个Region都有一个关联的Remembered Set。RSet记录了其他Region中的对象引用本Region中对象的关系,属于points-into结构(谁引用了我的对象)。RSet的价值在于使得垃圾收集器不需要扫描整个堆找到谁引用了当前分区中的对象,只需要扫描RSet即可。

如下图所示,Region1和Region3中的对象都引用了Region2中的对象,因此在Region2的RSet中记录了这两个引用。

 

 

 

  •  G1 GC则是在points-out的card table之上再加了一层结构来构成points-into RSet:每个region会记录下到底哪些别的region有指向自己的指针,而这些指针分别在哪些card的范围内。
  •  这个RSet其实是一个hash table,key是别的region的起始地址,value是一个集合,里面的元素是card table的index。 举例来说,如果region A的RSet里有一项的key是region B,value里有index为1234的card,它的意思就是region B的一个card里有引用指向region A。所以对region A来说,该RSet记录的是points-into的关系;而card table仍然记录了points-out的关系。
  •  Snapshot-At-The-Beginning(SATB):SATB是维持并发GC的正确性的一个手段,G1 GC的并发理论基础就是SATB。SATB是由Taiichi Yuasa为增量式标记清除垃圾收集器设计的一个标记算法。

  • 并发标记是并发多线程的,但并发线程在同一时刻只扫描一个分区。

 

G1相对于GMS的优势:

1、G1在压缩空间方面有优势

2、G1通过将内存空间分成区域(Region)的方式避免内存碎片问题

3、Eden, Survivor, Old区不再固定、在内存使用效率上来说更灵活

4、G1可以通过设置预期停顿时间(Pause Time)来控制垃圾收集时间避免应用雪崩现象(预测模型,统计数据分析)

5、G1在回收内存后会马上同时做合并空闲内存的工作、而CMS默认是在STW(stop the world)的时候做

6、G1会在Young GC中使用、而CMS只能在O区使用

 

 

 

重要概念:

  •  每个分区都可能是年轻代也可能是老年代,但是在同一时刻只能属于某个代。年轻代,幸存区,老年代这些概念还存在,成为逻辑上的概念,这样方便复用之前分代框架的逻辑。
  •  在物理上不需要连续,则带来了额外的好处---有的分区内垃圾对象特别多,有的分区内垃圾对象很少,G1会优先回收垃圾对象特别多的分区,这样可以花费比较少的时间来回收这些分区的垃圾,这也是G1名字的由来,即首先收集垃圾最多的分区。
  •  依然是在新生代满了的时候,对整个新生代进行回收---整个新生代中的对象,要么被回收,要么晋升,至于新生代也采取分区机制的原因,则是因为这样老年代的策略统一,方便调整代的大小。

 

 

G1的适合场景

  • 服务端多核CPU、JVM内存占用较大的应用(至少大于4G)
  • 应用在运行过程中会产生大量内存碎片、需要经常压缩空间
  • 想要更可控、可预期的GC停顿周期;防止高并发下应用雪崩现象

 

 

G1 GC模式:

  • G1提供了两种GC模式,Young GC和Mixed GC,两种都是Stop The World(STW)的
  •  Young GC: 选定所有年轻代里的Region。通过控制年轻代的Region个数,即年轻代内存大小,来控制Young GC的时间开销
  •  Mixed GC: 选定所有年轻代里的Region,外加根据global concurrent marking统计得出收集收益高的若干老年Region(垃圾对象多的老年代Region).在用户指定的开销目标范围内尽可能选择收益高的老年代Region
  •  Mixed GC不是Full GC,它只能回收部分老年代的Region,如果Mixed GC实在无法跟上程序分配内存的速度,导致老年代填满无法继续进行Mixed GC,就会使用serial old GC(Full GC)来收集整个GC heap。所以本质上,G1是不提供Full GC的
  • global concurrent marking,它的执行过程类似CMS,但是不同的是,在G1 GC中,它主要是为Mixed GC提供标记服务的,并不是一次GC过程的一个必须环节。

global concurrent marking的执行过程分为四个步骤:

初始标记(initial mark,STW)。它标记了从GC Root开始直接可达的对象。
并发标记(Concurrent Marking)。这个阶段从GC Root开始对heap中的对象标记,标记线程与应用程序线程并行执行,并且收集各个Region的存活对象信息。
重新标记(Remark,STW)。标记那些在并发标记阶段发生变化的对象,将被回收。
清除垃圾(Cleanup)。清除空Region(没有存活对象的),加入到free list。

第一阶段initial mark是共用了Young GC的暂停,这是因为他们可以复用root scan操作,所以可以说global concurrent marking是伴随Young GC而发生的。

第四阶段Cleanup只是回收了没有存活对象的Region,所以它并不需要STW。

G1在运行过程中的主要模式:

1. YGC(不同于CMS)

2. 并发阶段

3. 混合模式

4. Full GC(一般是G1出现问题时发生)

 

注:

在Eden充满时触发,在回收之后所有之前属于Eden的区块全变成空白。然后把剩余的存活对象移动到S区。

 

什么时候触发Mixed GC?

由一些参数控制,另外也控制着哪些老年代Region会被选入CSet(收集集合)

  • G1HeapWastePercent:在global concurrent marking结束之后,我们可以知道old gen regions中有多少空间要被回收,在每次YGC之后和再次发生Mixed GC之前,会检查垃圾占比是否达到此参数,只有达到了,下次才会发生Mixed GC。
  • G1MixedGCLiveThresholdPercent:old generation region中的存活对象的占比,只有在此参数之下,才会被选入CSet。
  • G1MixedGCCountTarget:一次global concurrent marking之后,最多执行Mixed GC的次数。
  • G1OldCSetRegionThresholdPercent:一次Mixed GC中能被选入CSet的最多old generation region数量

 

除了以上的参数,G1 GC相关的其他主要的参数有:

参数含义
-XX:G1HeapRegionSize=n 设置Region大小,并非最终值
-XX:MaxGCPauseMillis 设置G1收集过程目标时间,默认值200ms,不是硬性条件
-XX:G1NewSizePercent 新生代最小值,默认值5%
-XX:G1MaxNewSizePercent 新生代最大值,默认值60%
-XX:ParallelGCThreads STW期间,并行GC线程数
-XX:ConcGCThreads=n 并发标记阶段,并行执行的线程数
-XX:InitiatingHeapOccupancyPercent 设置触发标记周期的 Java 堆占用率阈值。默认值是45%。这里的java堆占比指的是non_young_capacity_bytes,包括old+humongous

 

 

G1收集概览:

  • G1算法将堆划分为若干区域(Region),它仍然属于分代收集器。不过,这些区域的一部分包含新生代,新生代的垃圾收集依然采用暂停所有应用线程的方式,将存活对象拷贝到老年代或者Survivor空间。老年代也分成很多区域,G1收集器通过将对象从一个区域复制到另一个区域,完成了清理工作。这就意味着,在正常的处理 过程中,G1完成了堆的压缩(至少是部分堆压缩),这样就不会有CMS内存碎片问题的存在。
  •  在G1中,还有一种特殊的区域,叫Humongous区域。 如果一个对象占用的空间超过了分区容量50%以上,G1收集器就认为这是一个巨型对象。这些巨型对象,默认直接会被分配在年老代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放巨型对象。如果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC
  • Young GC主要是对Eden区进行GC,它在Eden空间耗尽时会被触发。在这种情况下,Eden空间的数据移动到Survivor空间中,如果Survivor空间不够,Eden空间的部分数据会直接晋升到年老代空间。Survivor区的数据移动到新的Survivor区中,也有部分数据晋升到老年代空间中。最终Eden空间的数据为空,GC停止工作,应用线程继续执行。
  • 如果仅仅GC 新生代对象,我们如何找到所有的根对象呢? 老年代的所有对象都是根么?那这样扫描下来会耗费大量的时间。于是,G1引进了RSet的概念。它的全称是Remembered Set,作用是跟踪指向某个heap区内的对象引用。

  

 

 

  • 在CMS中,也有RSet的概念,在老年代中有一块区域用来记录指向新生代的引用。这是一种point-out,在进行Young GC时,扫描根时,仅仅需要扫描这一块区域,而不需要扫描整个老年代。
  • 但在G1中,并没有使用point-out,这是由于一个分区太小,分区数量太多,如果是用point-out的话,会造成大量的扫描浪费,有些根本不需要GC的分区引用也扫描了。

  • 于是G1中使用point-in来解决。point-in的意思是哪些分区引用了当前分区中的对象。这样,仅仅将这些对象当做根来扫描就避免了无效的扫描。

  • 由于新生代有多个,那么我们需要在新生代之间记录引用吗?这是不必要的,原因在于每次GC时,所有新生代都会被扫描,所以只需要记录老年代到新生代之间的引用即可。

  • 需要注意的是,如果引用的对象很多,赋值器需要对每个引用做处理,赋值器开销会很大,为了解决赋值器开销这个问题,在G1 中又引入了另外一个概念,卡表(Card Table)。一个Card Table将一个分区在逻辑上划分为固定大小的连续区域,每个区域称之为卡。卡通常较小,介于128到512字节之间。Card Table通常为字节数组,由Card的索引(即数组下标)来标识每个分区的空间地址

    Young GC 阶段:

    阶段1:根扫描
    静态和本地对象被扫描
    阶段2:更新RS
    处理dirty card队列更新RS
    阶段3:处理RS
    检测从年轻代指向年老代的对象
    阶段4:对象拷贝
    拷贝存活的对象到survivor/old区域
    阶段5:处理引用队列
    软引用,弱引用,虚引用处理

    关于G1 Mix GC: Mix GC不仅进行正常的新生代垃圾收集,同时也回收部分后台扫描线程标记的老年代分区。

    它的GC步骤分2步:

    1. 全局并发标记(global concurrent marking)
    2. 拷贝存活对象(evacuation)

 

提到并发标记,我们不得不了解并发标记的三色标记算法。它是描述追踪式回收器的一种有用的方法,利用它可以推演回收器的正确性。 首先,我们将对象分成三种类型的。

  • 黑色:根对象,或者该对象与它的子对象都被扫描
  • 灰色:对象本身被扫描,但还没扫描完该对象中的子对象
  • 白色:未被扫描对象,扫描完成所有对象之后,最终为白色的为不可达对象,即垃圾对象

 

根对象被置为黑色,子对象被置为灰色。

 

 

继续由灰色遍历,将已扫描了子对象的对象置为黑色。

 
 

遍历了所有可达的对象后,所有可达的对象都变成了黑色。不可达的对象即为白色,需要被清理。

 

 
 

这看起来很美好,但是如果在标记过程中,应用程序也在运行,那么对象的指针就有可能改变。这样的话,我们就会遇到一个问题:对象丢失问题。

 

我们看下面一种情况,当垃圾收集器扫描到下面情况时:

 

这时候应用程序执行了以下操作:

A.c=C

B.c=null

这样,对象的状态图变成如下情形:

 

 
 

这时候垃圾收集器再标记扫描的时候就会下图成这样:

 

 


引申SATB
 

在G1中,使用的是STAB(snapshot-at-the-beginning)的方式,删除的时候记录所有的对象,它有3个步骤:

1,在开始标记的时候生成一个快照图标记存活对象

2,在并发标记的时候所有被改变的对象入队(在write barrier里把所有旧的引用所指向的对象都变成非白的)

3,可能存在游离的垃圾,将在下次被收集

G1混合式回收

 这样,G1到现在可以知道哪些老的分区可回收垃圾最多。 当全局并发标记完成后,在某个时刻,就开始了Mix GC。这些垃圾回收被称作“混合式”是因为他们不仅仅进行正常的新生代垃圾收集,同时也回收部分后台扫描线程标记的分区。

 混合式GC也是采用的复制的清理策略,当GC完成后,会重新释放空间。

 

G1分代算法:

为老年代设置分区的目的是老年代里有的分区垃圾多,有的分区垃圾少,这样在回收的时候可以专注于收集垃圾多的分区,这也是G1名称的由来。

不过这个算法并不适合新生代垃圾收集,因为新生代的垃圾收集算法是复制算法,但是新生代也使用了分区机制主要是因为便于代大小的调整。

 

SATB:

SATB的全称是Snapchat-At-The_Beginning。SATB是维持并发GC的一种手段。G1并发的基础就是SATB。SATB可以理解成在GC开始之前对堆内存里的对象做一次快照,此时活的对象就认为是活的,从而形成一个对象图。

在GC收集的时候,新生代的对象也认为是活的对象,除此之外其他不可达的对象都认为是垃圾对象。除此之外其他不可达的对象都认为是垃圾对象。


如何找到在GC的过程中分配的对象呢(对象引用的变更,新对象的生成)?

每个region记录着两个top-at-mark-start(TAMS)指针,分别为prevTAMS和nextTAMS。在TAMS以上的对象就是新分配的,因而被视为隐式marked。

通过这种方式我们就找到了在GC过程中新分配的对象,并把这些对象认为是活的对象。


解决了对象在GC过程中分配的问题,那么在GC过程中引用发生变化的问题怎么解决呢,

G1给出的解决办法是通过Write Barrier。Write Barrier就是对引用字段进行赋值做了环切。通过Write Barrier就可以了解到哪些引用对象发生了什么样的变化。

 

SATB全称为Snapshot At The Beginning,其要点如下:

  • mark的过程就是遍历heap标记live object的过程,采用的是三色标记算法,这三种颜色为white(表示还未访问到)、gray(访问到但是它用到的引用还没有完全扫描)、back(访问到而且其用到的引用已经完全扫描完)。
  • 整个三色标记算法就是从GC roots出发遍历heap,针对可达对象先标记white为gray,然后再标记gray为black;遍历完成之后所有可达对象都是balck的,所有white都是可以回收的。
  • SATB仅仅对于在marking开始阶段进行“snapshot”(marked all reachable at mark start),但是concurrent的时候并发修改可能造成对象漏标记。
  • 对black新引用了一个white对象,然后又从gray对象中删除了对该white对象的引用,这样会造成了该white对象漏标记。
  • 对black新引用了一个white对象,然后从gray对象删了一个引用该white对象的white对象,这样也会造成了该white对象漏标记。
  • 对black新引用了一个刚new出来的white对象,没有其他gray对象引用该white对象,这样也会造成了该white对象漏标记。
对于三色算法在concurrent的时候可能产生的漏标记问题,SATB在marking阶段中,对于从gray对象移除的目标引用对象标记为gray,对于black引用的新产生的对象标记为black;由于是在开始的时候进行snapshot,因而可能存在Floating Garbage
 
 
对象的漏标和误标:
其实误标没什么关系,顶多造成浮动垃圾,在下次gc还是可以回收的,但是漏标的后果是致命的,把本应该存活的对象给回收了,从而影响的程序的正确性。

漏标的情况只会发生在白色对象中,且满足以下任意一个条件:

  1. 并发标记时,应用线程给一个黑色对象的引用类型字段赋值了该白色对象
  2. 并发标记时,应用线程删除所有灰色对象到该白色对象的引用

对于第一种情况,利用post-write barrier,记录所有新增的引用关系,然后根据这些引用关系为根重新扫描一遍

对于第二种情况,利用pre-write barrier,将所有即将被删除的引用关系的旧引用记录下来,最后以这些旧引用为根重新扫描一遍

停顿预测模型
G1收集器突出表现出来的一点是通过一个停顿预测模型来根据用户配置的停顿时间来选择CSet的大小,从而达到用户期待的应用程序暂停时间。通过-XX:MaxGCPauseMillis参数来设置。这一点有点类似于ParallelScavenge收集器。关于停顿时间的设置并不是越短越好。设置的时间越短意味着每次收集的CSet越小,导致垃圾逐步积累变多,最终不得不退化成Serial GC;停顿时间设置的过长,那么会导致每次都会产生长时间的停顿,影响了程序对外的响应时间。
通过-XX:MaxGCPauseMillis参数来设置。这一点有点类似于ParallelScavenge收集器。关于停顿时间的设置并不是越短越好。设置的时间越短意味着每次收集的CSet越小,导致垃圾逐步积累变多,最终不得不退化成Serial GC;停顿时间设置的过长,那么会导致每次都会产生长时间的停顿,影响了程序对外的响应时间。
 

G1的收集模式:

YoungGC:收集年轻代里的Region
MixGC:年轻代的所有Region+全局并发标记阶段选出的收益高的Region
无论是YoungGC还是MixGC都只是并发拷贝的阶段

分代G1模式下选择CSet有两种子模式,分别对应YoungGC和mixedGC:
YoungGC:CSet就是所有年轻代里面的Region
MixedGC:CSet是所有年轻代里的Region加上在全局并发标记阶段标记出来的收益高的Region

G1的运行过程是这样的,会在Young GC和Mix GC之间不断的切换运行,同时定期的做全局并发标记,在实在赶不上回收速度的情况下使用Full GC(Serial GC)。

 初始标记是搭在YoungGC上执行的,在进行全局并发标记的时候不会做Mix GC,在做Mix GC的时候也不会启动初始标记阶段。当MixGC赶不上对象产生的速度的时候就退化成Full GC,这一点是需要重点调优的地方。

 

G1调优实战:

1.不断调优暂停时间指标:

 通过XX:MaxGCPauseMillis=x可以设置启动应用程序暂停的时间,G1在运行的时候会根据这个参数选择CSet来满足响应时间的设置。一般情况下这个值设置到100ms或者200ms都是可以的(不同情况下会不一样),但如果设置成50ms就不太合理。暂停时间设置的太短,就会导致出现G1跟不上垃圾产生的速度。最终退化成Full GC。所以对这个参数的调优是一个持续的过程,逐步调整到最佳状态。

 

2.不要设置新生代和老年代的大小

 G1收集器在运行的时候会调整新生代和老年代的大小。通过改变代的大小来调整对象晋升的速度以及晋升年龄,从而达到我们为收集器设置的暂停时间目标。

 设置了新生代大小相当于放弃了G1为我们做的自动调优。我们需要做的只是设置整个堆内存的大小,剩下的交给G1自己去分配各个代的大小。

 

3. 关注Evacuation Failure

Evacuation Failure类似于CMS里面的晋升失败,堆空间的垃圾太多导致无法完成Region之间的拷贝,于是不得不退化成Full GC来做一次全局范围内的垃圾收集 。

 

-XX:MaxGCPauseMillis=200m  ##最大GC停顿时间是200m

 

posted @ 2019-10-02 03:06  toov5  阅读(1233)  评论(0编辑  收藏  举报