深入理解Java虚拟机

JVM虚拟机

  • java技术体系

    • 程序设计语言<----->编译器<----->class文件<----->虚拟机

      • class文件格式规范给定,可以自己设计语言,自己编写编译器,生成相同的class文件即可
      • 只要class文件的规范给定,不同的编程语言根据不同的编译器进行编译,生成符合规范的class文件,就能运行在JVM虚拟机上

一. 内存结构

1.1 运行时数据区域

深入理解Java虚拟机_第1张图片

  1. 程序计数器

    • 程序计数器是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器,字节码解释器工作就是通过改变这个计数器的值来选取下一条需要执行指令的字节码指令
    • 程序计算器处于线程独占区
    • 如果线程执行的是java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址,如果正在执行的是native方法,这个计数器的值为undefine
    • 此区域是唯一一个在java虚拟机规范没有规定任何OutOfMemoryError情况的区域
  2. 虚拟机栈

    • 虚拟机栈描述的是Java方法执行的动态内存模型
    • 栈帧

      • 每个方法执行,都会创建一个栈帧,伴随着方法从创建到执行结束.用于存储局部变量表,操作数栈,动态链接,方法出口等.每一个方法从调用直到执行结束,就对应着一个栈帧从虚拟机栈中入栈到出栈的过程
    • 局部变量表

      • 存放编译器可知的各种基本数据类型,引用类型,returnAddress类型
      • 局部变量表的内存空间在编译期完成分配,当进入一个方法时,这个方法需要在帧分配多少内存是固定的,在方法运行期间是不会改变局部变量表的大小
  3. 本地方法栈

    • 虚拟机栈为虚拟机执行java方法服务
    • 本地方法栈为虚拟机执行native方法服务
  4. Java堆

    • JVM锁管理的内存中最大的一块,线程共享,主要存放对象和数组
    • 内部会划分出多个线程私有的分配缓冲区TLAB(Thread Local Allocation Buffer),可以位于物理上不连续的空间,但是逻辑上要连续
    • 垃圾收集器管理的主要区域
    • 新生代,老年代,Eden
  5. 方法区

    • 属于共享内存区域,存储虚拟机加载的类信息,常量,静态变量,即是编辑器编译后的代码等数据

      • 魔数
      • 类的版本
      • 字段
      • 方法
      • 接口
      • 运行时常量池

        • 属于方法区的一部分,用于存放编译器生成的各种字面量和符号引用
        • 编译器和运行期(String的intern())都可以将常量放入池中
  6. 直接内存

    • 非虚拟机运行时数据区的部分
    • 在JDK1.4中新加入NIO,引入一种基于管道(Channel)和缓冲(Buffer)的I/O方式,它可以使用Native函数直接分配对外内存,然后用过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,可以避免在Java堆和Native堆中来回的数据耗时操作

深入理解Java虚拟机_第2张图片

  • 附:

    • 堆体系结构

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

        • 新生代(Young Generation,称YoungGen),位于堆空间

          • 新生代又分为Eden区和Survior区

            • Eden:新创建的对象
            • Survior:经过垃圾回收,但是垃圾回收次数小于15次的对象
        • 老年代(Old Generation,称OldGen,TenuringGen),位于堆空间

          • 垃圾回收次数超过15次,且依然存活的对象
        • 永久代(Permanent Geneartion,称PermGen),位于非堆空间---方法区

          • 永久代是一个常驻内存区域,用于存放JDK自身所携带的class,interface的元数据,也就是说它存储的是运行环境必须的类信息,被装进此区域的数据是不会被垃圾回收器回收的,关闭JVM才会释放此区域所占的内存


* java8删除了堆中的永久代,增加了元空间

  * java7之前hotspot虚拟机对方法区的实现为永久代(PermGen),永久代和堆相互隔离,永久代的大小在启动JVM时可以设定一个固定值
  * java7中,字符串变量从永久代移到堆中
  * java8中,移除了永久代,元空间(Metaspace)作为方法区的实现,永久代与堆不相连,但与堆物理共享内存,逻辑上可以认为在堆中,元空间在本地内存

  
  • 堆的生命周期

    • 新生代

      • 新生代是对象的诞生,成长,消亡的区域,一个对象在这里产生,应用,最后被垃圾回收器收集,销毁
      • 新生代分为两部分:Eden Space和Survior Space,所有对象都是在Eden区被new出来
      • Survior区有两个:0区(Survior 0 space)和1区(Survior 1 space),当Eden区的空间用完时,程序又需要创建新对象时,JVM的垃圾回收器将对Eden进行垃圾回收Minor GC
      • 将Eden中不再被其他对象所引用的对象进行销毁,然后将Enden中剩余对象移动到Survior 0区,当Survior 0区满了,再将该区进行垃圾回收,然后移动到Survior 1区,如此反复,达到一定年龄的对象将移入老年代
    • 老年代

      • 若老年代也满了,这个时候会产生Major GC(Full GC),进行老年代的内存清理,若老年代执行Full GC之后发现仍然无法进行对象的保存,就会产生OOM

        • 若出现java.lang.OutOfMemoryError:Java head space异常,说明Java虚拟机的堆内存不够

          • Java虚拟机的堆内存不够,可以通过参数-Xms(初始堆内存),-Xmx(最大堆内存)来调整
          • 代码中创建了大量大对象,并且超时间不能被垃圾收集器清理(存在被引用)
    • 元空间

      • 元空间的本质和永久代类似,都是对JVM规范中方法区的实现,不过元空间和永久代最大的区别在于:元空间并不在虚拟机中,而是使用本地内存.因此,默认情况下,元空间仅受本地内存限制,但是可以通过以下参数来指定元空间的大小

        • -XX:MetaspaceSize:初始空间大小,达到该值会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值.如果释放很少的空间,那么在不超过MaxMetaspaceSize的情况下,适当提高该值
        • -XX:MaxMetaspaceSize:最大空间,默认无限制
  • 常量池

    • 方法区存着类的信息,常量,静态变量,即类被编译后的数据.具体来说,方法区存放着类的版本,字段,方法,接口,常量池

      深入理解Java虚拟机_第3张图片

      • 常量池

        • 字面量

          • 文本字符串
          • 被声明为Final的常量值
          • 基本数据类型的值
          • 其他
        • 符号引用

          • 类和结构的完全限定名
          • 字段名称和描述符
          • 方法名称和描述符
      • class文件信息

        深入理解Java虚拟机_第4张图片

        • class文件中存储的数据类型

          深入理解Java虚拟机_第5张图片

        • 反编译class文件

          深入理解Java虚拟机_第6张图片

    • 静态常量池(class文件常量池)和动态常量池的区别

      • 静态常量池储存的是当class文件被java虚拟机加载进来后存储在方法区的一些字面量和符号引用,字面量包括字符串,基本类型的常量,符号引用其实引用的就是常量池里面的内容,但符号引用不是直接存储字符串,而是存储字符串在常量池中的索引

        • 位于class文件中,javap -verbose反编译展示的字节码即为静态常量池
      • 动态常量池式当class文件被加载完成后,java虚拟机将静态常量池里的内容转移到动态常量池里,在静态常量池的符号引用有一部分是会被转变为直接引用的,比如类的静态方法或私有方法,实例构造方法,父类方法,这是因为这些方法不能被重写成其他版本,所以能在加载的时候可以将符号引用转变为直接引用,而其他的一些方法则需要在这个方法被第一次调用时才将符号引用转变为直接引用

        • 在class文件被加载进内存后,常量池保存在了方法区中,通常说的常量池为运行时常量池
      • 总结

        • 方法区里存储着class文件的信息和动态常量池,class文件的信息包括类信息和静态常量池.可以将类的信息是对class内容的一个框架,里面具体的内容可以通过常量池来存储
        • 动态常量池里的内容除了是静态常量池里的内容外,还将静态常量池里的符号引用转变为直接引用,而且动态常量池里的内容是能动态添加的---如String#intern方法

          • 这里String常量池是包含在动态常量池中的,但在jdk1.8后将String常量池放到了堆中
> 1. 字符串常量池
>
>    ```java
>    String a = "abc";
>    String b = new String("abc");
>    System.out.println(a == b); //false
>    
>    //对象b存储在堆中,a作为字面量一开始存储在了class文件中,之后运行期,转存至方法区中,所以两者比较为false
>    ```
>
>    ```java
>        String s1 = "Hello";
>        String s2 = "Hello";
>        String s3 = new String("Hello");
>        String s4 = "Hel" + "lo";
>        String s5 = "Hel" + new String("lo");
>        String s6 = s5.intern();
>        String s7 = "H";
>        String s8 = "ello";
>        String s9 = s7 + s8;
>    
>    //s1和s2都指向了方法区常量池中的Hello
>        System.out.println(s1 == s2);  // true
>    //一个在常量池,一个在堆中
>        System.out.println(s1 == s3);  // false
>    //因为做+号运算,若常量池有结果字符串,则返回
>        System.out.println(s1 == s4);  // true
>    //在+号运算时进行动态调用,最后仍然是一个String对象存在堆中
>        System.out.println(s1 == s5);  // false
>    //intern()方法:首先在常量池中查找是否存在一份相同的字符串,如果有就返回该字符串的引用,否则就加入到字符串常量池中,动态常量池是可以改变的
>        System.out.println(s1 == s6);  // true
>    //Java9,因为是动态调用,所以返回的是一个新的String对象
>        System.out.println(s1 == s9);  // false
>    
>    ```
>
>    ```java
>    1.常量拼接
>    
>    public static final String a = "123";
>    public static final String b = "456";
>    
>    public static void main(String[] args){
>        String c = "123456";
>        String d = a+b;
>        System.out.println(c == d);//true
>    }
>    ----反编译结果----
>        0:ldc        #2        //String 123456
>        2:astore_1
>        3:ldc        #2        //String 123456
>        5:astore_2
>        6:getstatic    #4
>    //对于final类型的常量,他们已经在编译中就确定下来,自动执行了+号,把他们拼接起来所以相当于"123456"
>    
>    
>    2.static静态代码块
>    
>    public static final String a;
>    public static final String b;
>    
>    static{
>        a = "123";
>        b = "456";
>    }
>    
>    
>    public static void main(String[] args){
>        String c = "123456";
>        String d = a + b;
>        System.out.println(c == d);
>    }
>    ------反编译结果-----
>        3: getstatic    #3    //Field a:Ljava/lang/String
>        6: getstatic    #4    //Field b:Ljava/lang/String
>        9: invokeddynamic    #5    //invokeDynamic #0:makeConcatWithConstants
>        
>    //上一个例子是在编译期间,就已经确定a和b,而这段代码中,编译期static不执行的,a和b的值是位置的,static代码块在初始化的时候被执行,而初始化属于类加载的一部分,属于运行期,在反编译的指令中使用了indy指令,动态调用返回String类型对象,位于堆区而不是常量池中
>    
>    
>    3.创建了几个对象
>    String s1 = new String("xyz");
>    1. 类加载对一个类只会进行一次,"xyz"在类加载时就已经创建并驻留(intern)了(如果该类被加载之前已经有"xyz"字符串被驻留过则不需要重复创建用于驻留的"xyz"实例),驻留的字符串是存放在全局共享的字符串常量池中
>    2. 这段代码在后续被运行的时候,"xyz"字面量对应的String实例已经固定,不会再被重复创建,所以这段代码将常量池中的对象复制一份放到heap中,并把heap中的这个对象引用交给s1持有
>    3.这条语句创建了两个实例对象,一个是全局共享的字符串常量池中的实例,一个new String创建的引用实例
>    
>    
>    String s2 = "a" + "b" + "c";
>    1. 实际上在编译期,已经将这三个字面量合成了一个,这样实际上是一种优化,避免创建了多余字符串对象,也没有字符串拼接问题
>    2. 只创建一个对象,在常量池中只保存一个引用
>    ```
>
> 2. 包装类的常量池技术
>
>    * jdk1.5中引入了自动拆箱和自动装箱机制,自动装箱常见的是valueOf这个方法,自动拆箱就是intValue方法,除了包装类Long和Double没有实现这个缓存技术,其他的包装类都实现了它
>
>      ```java
>      public static Integer valueOf(int i){
>          if(i >= IntegerCache.low && i <= IntegerCache.high){
>              return IntegerCache.cache[i + (-IntegerCache.low)];
>          }
>          return new Integer(i);
>      }
>      
>      
>      private static class IntegerCache{
>          static final int low = -128;
>          static final int high;
>          static final Integer cache[];
>          
>          static{
>              //high value may be configured by property
>              int h = 127;
>              String integerCacheHighPropValue = VM
>                  .getSaveProperty("java.lang.Integer.IntegerCache.high");
>              if(IntegerCacheHighPropValue != null){
>                  try{
>                      int i = parseInt(integerCacheHighPropValue);
>                      i = Math.max(i,127);
>                      //Maxmun array size is Integer.MAX_VALUE
>                      h = Math.min(i,Integer.MAX_VALUE - (-low) -1);
>                  }catch(NumberFormatException nfe){
>                  //if the property cannot be parsed into an int,ignore it
>                  }
>              }
>              high = h;
>              
>              cache = new Integer[(high - low) + 1];
>              int j = low;
>              for(int k = 0;k < cache.length;k++){
>                  cache[k] = new Integer(j++);
>              }
>              
>              //range[-128,127]must be interned
>                  assert IntegerCache.high >= 127;
>          }
>          
>          private IntegerCache(){}
>      }
>      ```
>
>    * 可以看到从-128~127的数全部自动加入到了常量池里面,以为这个段的数使用的常量值的地址都是一样的
>
>      ```java
>      Integer a = 40;
>      Integer b = 40;
>      Double c = 40.0;
>      Double d = 40.0;
>      
>      System.out.print(a == b);//true
>      System.out.print(c == d);//false
>      
>      /*
>      1.==这运算在不出现算数运算符的情况下,不会自动拆箱,所以a和b他们不是数值进行比较,仍然是比较地址是否指向同一地址内存
>      2.他们都在常量池存储
>      3.编译阶段已经将代码转成了调用valueOf方法,使用的是常量池,如果超过范围则创建新的对象
>      */
>      
>      Integer a1 = 40;
>      Integer a2 = 40;
>      Integer a3 = 0;
>      Integer a4 = new Integer(40);
>      Integer a5 = new Integer(40);
>      Integer a6 = new Integer(0);
>      Integer b1 = 400;
>      Integer b2 = 400;
>      
>      System.out.println(a1 == a2);//true
>      System.out.println(a1 == a2 + a3);//true
>      System.out.println(a1 == a4);//false
>      System.out.println(a4 == a5);//false
>      System.out.println(a4 == a5 + a6);//true
>      System.out.println(400 == a5 + a6);//true
>      System.out.println(b1 == b2);//false
>      
>      /*
>      1.当出现运算时,Integer不能直接用来运算,所以会进行一次自动拆箱为基本数据进行比较
>      2.==这个符号,既可以比较基本类型,也可以比较内存地址,当进行运算时比较数据大小,否则比较内存地址是否相同
>      3.a1,a2是指向常量池中同一个地址,a3也是位于常量池中
>      4.a4,a5,a6位于堆中,并各自指向不同的对象
>      5.b1,b2超过范围,都是堆中的新对象
>      */
>      ```

1.2 虚拟机对象

1.2.1 对象的创建

  1. 遇到new指令时,首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用是否已经被加载,解析和初始化过,如果没有,执行相应的类加载
  2. 类加载检查通过之后,为新对象分配内存(内存大小在类加载完成后即可确认),在堆的空闲内存中划分一块区域

    1. 指针碰撞-内存规整
    2. 空闲列表-内存交错
  3. 每个线程在堆中都会有私有的分配缓冲区TLAB,这样可以很大程度避免在并发情况下频繁创建对象造成的线程不安全
  4. 内存空间分配完成后会初始化为0(不包括对象头),接下来就是填充对象头,把对象是哪个类的实例,如何才能找到类的元数据信息,对象的哈希码,对象的GC分代年龄等信息存入对象头
  5. 执行new指令后执行init方法后才算一份真正可用的对象创建成功

1.2.2 对象的内存布局

  • 子HotSpot虚拟机中,分为三块区域:对象头(Header),实例数据(Instance Data)和对齐填充(Padding)

    1. 对象头:

      1. 自身运行时数据(Mark Word)

        • 哈希码,GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等
      2. 类型指针

        • 即对象指向它的类的元数据指针,虚拟机通过这个指针确定这个对象是哪个类的实例
    2. 实例数据:

      • 程序代码中所定义的各类数据的字段内容(包括父类继承下来的和子类中定义的)
      • 函数的局部变量在堆栈上,类成员变量在堆上,还有常数在常量池

        public class Test{
            public static A a2;
        //方法逃逸
            public A run1(){
                A a1=new A();
                return a1;
            }
        //线程逃逸
            public void run2(A a2){
                this.a2=a2;
            }
        //无逃逸
            public void run3(){
                A a3=new A();
            }
        }
        
        对象引用a1在方法run1中被定义并返回,此为方法逃逸,该对象分配到堆
        
        类变量a2在方法run2被赋值,因为该类变量可被其他线程访问,此为线程逃逸,该对象分配到堆
        
        对象引用a3在方法run3中被定义,且为无逃逸,该对象可以被分配到栈
    3. 对齐填充:

      • 不是必然需要,主要是占位,保证对象大小是某个字节的整数倍
  • 对象的内存分配

    • 栈上分配

      • VM在Server模式下的逃逸分析可以分析出某个对象是否永远只在某个方法,线程范围内,并没有"逃逸"出这个范围,逃逸分析的一个结果就是对于某些未逃逸对象可以直接在栈上分配,由于该对象一定是局部的,所以栈上分配不会有问题.
      • 在实际的应用程序,尤其是大型程序中反而发现实施逃逸分析可能出现效果不稳定,或因分析过程耗时但无法有效判别出非逃逸对象而导致性能(即时编译的收益)有所下降,所以很长一段时间里没及时Server Compiler,也默认不开启逃逸分析
    • TLAB分配

      • 一种是对分配内存空间的动作进行同步处理----实际上虚拟机采用CAS和失败重试的方式保证更新操作的原子性
      • 另一种是把内存分配的动作按照线程划分在不同的空间之中记性,即每个线程在Java堆中预先分配一块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer TLAB)
    • 堆分配

      • 我们知道堆是由所有线程共享的,既然如此那它就是竞争资源,对于竞争资源,必须采取必要的同步,所以当使用new关键字在堆上分配对象时,是需要锁的.既然有锁,那必定存在锁带来的开销,而且由于是对整个堆加锁,相对而言锁的粒度还是比较大的,影响效率,而无论是TLAB还是栈都是线程私有的,私有即避免了竞争

1.2.3 对象的访问定位

  • 使用对象时,通过栈上的reference数据来操作堆上的具体对象
  • 对象的访问定位

    • 使用句柄:Java堆中会分配一块内存作为句柄池,reference存储的是句柄地址

      深入理解Java虚拟机_第7张图片

    • 直接指针---HotSpot使用方式

      深入理解Java虚拟机_第8张图片

      • 比较:

        • 使用句柄的最大好处是reference中存储的是稳定的句柄地址,在对象移动(GC)时只改变实例数据指针地址,reference自身不需要修改
        • 直接指针访问的最大好处就是速度快,节省了一次指针定位的时间开销
        • 如果是对象频繁GC,那么句柄方式好,如果对象频繁访问则直接指针方式更好

二. 垃圾回收机制

  • 程序计数器,虚拟机栈,本地方法栈3个区域随着线程的生命周期生亡(因为线程私有),栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作.而java堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期才知道哪些对象会创建,这部分内存的分配和回收都是动态的,垃圾回收所关注的也就是这部分内存

2.1 如何判定对象为垃圾

  1. 引用计数法

    • 栈中的引用指向堆中的实例,当引用置为null时,实例的计数减少1,当计数为0时,即被回收

      • 一般不使用此方法,因为当几个实例内部相互引用,而并没有栈引用时,仍然不会进行回收,存在循环引用的问题
      • 垃圾回收日志参数:-verbose:gc -XX:+PrintGCDetails
 ![image.png](/img/bVbHxNR)
  1. 可达性分析法

    • 通过一系列的"GC Roots"的作为起始点,从这些节点出发所走过的路径成为引用链,当一个对象到GC Roots没有任何引用链相连的时候说明对象不可用

      • 可作为GCRoots的对象

        • 虚拟机栈(栈帧中的本地变量表)中引用的对象
        • 本地方法栈中引用的对象
        • 方法区的类属性所引用的对象
        • 方法区中常量所引用的对象
 ![image.png](/img/bVbHxNS)
  1. 引用

    • 强引用:类似于Object obj = new Object();创建的,只要强引用存在就不会回收
    • 软引用:SoftReference类实现软引用,在系统要发生内存溢出异常之前,将会把这些对象列进回收范围中进行二次回收
    • 弱引用:WeakReference类实现弱引用,对象只能生存到下一次垃圾回收之前,在垃圾收集器工作时,无论内存是否足够都会回收只被弱引用关联的对象
    • 虚引用:PhantomReference类实现虚引用,无法通过虚引用获取一个对象的实例,为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知

2.2 如何回收

  • 回收策略

    1. 标记-清除法:

      • 直接标记清除即可
      • 不足

        • 效率不高
        • 空间会产生大量碎片
    2. 复制算法:

      • 把空间分成两块,每次只对其中一块进行GC,将还存活的对象复制到另一块
      • 解决前一种方法的不足,但是会造成利用率低下,因为大多数新生代都不会熬过第一次GC,所以没必要1:1划分空间,可以划分为一块较大的Eden空间和两块较小的Srvior空间,每次使用Eden空间和其中一块Survior空间.当回收时,将Eden和Sruvior中还存活的对象一次性复制到另一块Survior上,最后清理Eden和Survior空间,大小比例为8:1:1,每次只浪费10%的Survior空间
      • 但是这里有一个问题就是如果存活的大于10%时就会有问题,这时就可以采取一种分配担保策略:多余的对象直接进入老年代
    3. 标记-整理法

      • 不同于针对新生代的复制算法,针对老年代的特点,创建该算法,主要把存活的对象移到内存的一端
    4. 分带收集法

      • 根据存活对象划分几块内存区,一般分为新生代和老年代,然后根据各个年代的特定定制相应的回收算法
      • 新生代每次垃圾回收都有大量的对象死去,只有少量对象存活,所以使用复制算法比较合理
      • 老年代中对象存活率高,没有额外的空间分配对它进行担保,所以必须使用标记-整理算法

2.3 何时回收

  1. serial

    • 这是一个单线程收集器,意味着它只会使用一个CPU或一条收集线程去完成收集工作,并且在进行垃圾回收时必须暂停其它所有的工作线程直到收集结束

深入理解Java虚拟机_第9张图片

  1. parnew

    • 可以认为是Serial收集器的多线程版
    • Parallel(并行):指多条垃圾收集线程并行工作,此用户线程处于等待状态
    • Concurrent(并发):指用户线程和垃圾回收线程同时执行(不一定并行,有可能是交叉执行),用户进程在运行,而垃圾回收线程在另一个CPU上运行

深入理解Java虚拟机_第10张图片

  1. Parallel Scavenge;

    • 这是一个新生代收集器,也是使用的复制算法实现,同时也是并行的多线程收集器
    • CMS等收集器的关注点是尽可能的缩短垃圾收集时用户线程等待的时间,而Parallel Scavenge收集器的目的是达到了一个可控制的吞吐量(用户线程运行时间/(用户线程运行的时间 + 垃圾收集的时间)
    • 作为一个吞吐量优先的收集器,虚拟机会根据当前系统的运行情况,动态调节停顿时间

深入理解Java虚拟机_第11张图片

  1. Cms

    • CMS(Concurrent Mark Sweep)收集器是一种以获得最短停顿时间为目标的收集器
    • 运行步骤

      1. 初始标记(CMS initial mark):标记GC Roots能直接关联到的对象
      2. 并发标记(CMS Concurrent mark):进行GC Roots Tracing
      3. 重新标记(CMS Remark):修正并发标记期间的变动部分
      4. 并发清除(CMS concurrent sweep)
    • 缺点:对CPU资源敏感,无法收集浮动垃圾

深入理解Java虚拟机_第12张图片

  1. G1

    • 面向服务端的垃圾回收器
    • 运行步骤

      1. 初始标记(initial Marking)
      2. 并发标记(Concurrent Marking)
      3. 最终标记(Final Marking)
      4. 筛选回收(Live Data Concurrent And Evacuation)
    • 优点:并行与并发,分代收集,空间整合,可预测停顿等

深入理解Java虚拟机_第13张图片

2.4 内存分配和回收策略

  • Java对象分配流程

    • 对象不在堆上分配的主要原因是因为堆共享,在堆上需要新增同步开销,无论是TLAB还是栈都是线程私有的,私有即避免了锁的竞争,这是典型的空间换效率的做法

深入理解Java虚拟机_第14张图片

  1. 编译器通过逃逸分析,确地对象是在栈上分配还是在堆上分配,如果是在对上分配,则尝试TLAB分配
  2. 如果TLAB_top + size <= TLAB_end,则在TLAB上直接分配对象并增加TLAB_top的值,如果TLAB不足以存放当前对象,则重新申请一个TLAB,并尝试再次存放当前对象
  3. 如果仍然放不下,则在Eden区加锁(这个区是多线程共享的),如果Eden_top + size <= Eden_end,则将对象存放在Eden区,增加Eden_top的值
  4. 如果Eden区放不下,则执行一次Minor GC,如果Eden区仍然放不下,则直接分配到老年代
  • 对象分配原则

    1. 优先分配到Eden区

      • 对象主要分配在新生代的Eden区,如果启动了本地线程分配缓冲区TLAB,则优先分配在TLAB上,少数情况会直接分配到老年代中

        • 新生代GC(Minor GC):发生在新生代的垃圾回收动作,频繁,速度快
        • 老年代GC(Major/Full GC):发生在老年代的垃圾回收动作,出现Full GC经常会伴随至少一次Minor GC(非绝对),Major GC的速度一般会比Minor GC慢十倍以上

深入理解Java虚拟机_第15张图片

/*
-verbose:gc -XX:PrintGCDetails 表示输出虚拟机中GC的详细情况
-Xms20M -Xmx20M -Xmn10M 设置内存大小为20M,新生代大小为10M
-XX:SurviorRatio=8 设置Eden和Survior的比值大小为8:1
*/

public static void main(String[] args){
    byte[] b1 = new byte[2 * 1024 * 1024];
    byte[] b2 = new byte[2 * 1024 * 1024];
    byte[] b3 = new byte[2 * 1024 * 1024];
    byte[] b4 = new byte[4 * 1024 * 1024];//第一次MinorGC
    
    System.gc();
}

JVM优先把对象放入Eden区,当Eden区放不下后(2 * 3 = 6),通过分配担保机制放入老年代6M(Minor GC),再把最后一个4M放入新生代
  1. 大对象直接分配到老年代
  2. 长期存活的对象分配到老年代
  3. 空间分配担保
  4. 动态对象的年龄判断
  • GC触发时机

    • Minor GC:

      • minorGC:1.Eden区满了或者新创建的对象大于Eden区的剩余空间
    • Full GC:

      • 当调用System.gc时,系统建议执行fullGC,但不是必然执行
      • 老年代空间不足
      • 通过minorGC进入老年代的数据大于老年代剩余的空间

2.5 GC优化

  • GC优化的两个目标

    1. 将进入老年代的对象数量降到最低-----减少Full GC的频率

      • 对象在Eden区被创建,随后被转移到了Survior区,因此之后剩余的对象会被转入老年代,也有一些对象由于占用内存过大,在Eden区被创建后直接进入老年代,老年代GC相对来说比新生代GC更加耗时,因此,减少进入老年代的对象数量可以显著提高Full GC的频率
    2. 减少Full GC的时间

      • Full GC的执行时间比Minor GC要长很多,因此,如果在Full GC上花费过多的时间(超过1s),将可能出现超时错误.可以通过减小老年代内存大小使得Full GC的时间降低,但是减小老年代的内存大小又会增加Full GC的频率,所以两者需要一个平衡
  • 优化方向:

    1. GC优化是最后不得已才采用的手段
    2. 一般来说堆越大越好,能够降低GC的频率,但增加堆内存,会造成单次GC需要遍历处理的对象更多,耗时更长,也会受服务器硬件的限制无法无限大,所以需要找到一个平衡点
    3. 通常堆参数-Xms和-Xmx可以设置相等,放置垃圾收集器在最小和最大之间收缩堆而产生额外的消耗,消耗性能
    4. 新生代/老年代大小比例合适:

      • 新生代过小,发生Minor GC频繁,且大对象容易直接进入老年代
      • 新生代过大,老年代变小,容易Full GC频繁,Minor GC耗时大幅度增加
      • 建议新生代/老年代比例为3/8

2.6 常见的JVM异常

  • OutOfMemory(OOM)

    • OutOfMemory,即内存溢出,是一个常见的JVM问题,一般满足以下两个条件时会抛出

      1. JVM 98%的时候会花费在内存回收
      2. 每次回收的内存小于2%
    • 三种OOM异常

      1. OutOfMemoryError:Java heap space - 堆空间溢出
      2. OutOfMemoryError:PermGen space - 方法区和运行时常量溢出
      3. OutOfMemoryError:unable to create new native thread - 线程无法创建
  1. OutOfMemoryError:Java heap space:表示堆空间溢出

    • 原因:JVM 分配给堆内存的空间已经满了
    • 问题定位

      1. 使用jmap或-XX:+HeapDumpOnOutOfMemoryError获取堆快照
      2. 使用内存分析工具(visualvm,mat,jProfile)对堆快照进行分析
      3. 根据分析图,重点是确认内存中的对象是否必要的,分清究竟是内存泄漏(Memory Leak)还是内存溢出
    • 内存泄漏

      • 没用了的内存没有及时释放导致最后占满内存
    • 内存泄漏的几个常见情况

      1. 静态集合类:声明为静态的HashMap,Vector等集合,通常来讲A中有B,当前只把B设置为空,A没有设置为空,回收时B无法回收---因为被A引用
      2. 监听器:监听器被注册后释放对象没有删除监听器
      3. 物理连接:DataSource.getConnect()建立的连接,必须通过close()关闭连接
      4. 有死循环或不必要的重复创建大量对象
  2. OutOfMemoryError:PermGen space:表示方法区和运行时常量池溢出

    • 原因

      • perm区主要存放Class和Meta信息,Class在被Loader时就会被放到PermGen space,这个区域称为永久代,GC在主程序运行期间不会对永久代进行清理,默认64M
      • 当程序中使用了大量的jar或class,使java虚拟机装载类的空间不够,超过64M就会报出这部分内存溢出了,需要加大内存分配
    • 解决方案

      1. 扩大永久代空间

        • JDK7之前使用-XX:PermSize和-XX:MaxMetaspaceSize控制永久代大小
        • JDK8以后把原本放在永久代的字符串常量移到Java堆中(元空间Metaspace)中,元数据并不在虚拟机中,使用的是本地内存

          • 使用-XX:MetaspaceSize和-XX:MaxMetaspaceSize控制元空间
      2. 清理应用程序下WEB-INF/lib下的jar,删除不用的jar,多个应用公共的jar移动到Tomcat的lib目录.减少重复加载
  3. 优化参数

    堆配置:
    -Xms:初始堆大小
    -Xms:最大堆大小
    -Xss: 每个线程的堆栈大小
    -XX:NewSize=n:设置年轻代大小
    -XX:NewRatio=n:设置年轻代和年老代的比值。
    -XX:SurvivorRatio=n:年轻代中Eden区与两个Survivor区的比值。
    -XX:MaxMetaSpaceSize=n:最大元空间大小
    -XX:+CollectGen0First : FullGC时是否先YGC
    收集器设置:
    -XX:+UseSerialGC:设置串行收集器
    -XX:+UseParallelGC:设置并行收集器
    -XX:+UseParalledlOldGC:设置并行年老代收集器
    -XX:+UseConcMarkSweepGC:设置并发收集器
    -XX:ParallelGCThreads 并行收集器的线程数
    打印GC:
    -XX:+PrintGC
    -XX:+PrintGCDetails
    -XX:+PrintGCTimeStamps
    -Xloggc:filename

三. 性能监控工具

  • 虚拟机工具

    • Jps

      • java process status,可以查看本地虚拟机唯一id lvmid(local virtual machine id)
      • 参数

        • -m:运行时传入主类的参数
        • -v:虚拟机参数
        • -l:运行时主类全名
    • Jstat

      • 监视虚拟机运行时的状态信息
      • jstat -gcutil 8080 1000 10:每隔1000毫秒执行1次,一共10次
    • Jinfo

      • 实时查看和调整虚拟机的各项参数
      • 例如:

        • -XX:-UseSerialGC 不启动Serial垃圾回收器
        • -XX:+UseG1GC 启用G1垃圾回收器
    • Jmap

      • 生成java程序的dump文件
    • Jhat

      • JVM heap Analysis Tool,十分占据内存和CPU,使用较少
    • Jstack

      • 生成线程快照,定位线程长时间停顿的原因
      • 命令:jstack [option] vmid
    • Jconsole

      • 一种基于JMX的可视化监控,管理工具,进行内存管理,线程管理,查看死锁等

四. 性能调优案例

  1. 案例一:

    • 背景:绩效考核系统,会针对每一个考核员工生成一个各考核点的考核结果,形成一个Excel文档,供用户下载.文档中包含用户提交的考核点信息以及分析信息,Excel文档由用户请求的时候生成,下载并保存在内存服务器一份
    • 问题:经常用户反映长时间顿卡的问题
    • 处理思路

      • 优化SQL(无效,SQL一般不会出现不规律问题)
      • 监控CPU
      • 监控内存发现经常Full GC 20-30s

        • 运行时产生大对象(每个教师考核的数据WorkBook),直接放入老年代,Minor GC不会去清理,会导致Full GC,且堆内存分配太大,时间过长
    • 解决方案:部署多个Web容器,多个Web容器分摊数据,减少内存老年代产生Full GC的次数
  2. 案例二:

    • 背景:简单数据抓取系统,抓取网络上的一些数据,分发到其他应用
    • 问题:不定期内存溢出,把堆内存加大也无济于事
    • 处理的方法:NIO使用了对外内存,对外内存无法垃圾回收,导致溢出

五. 类的文件结构

  • 无关性

    • Java语言选择了与操作系统和机器指令集无关的,平台中立的格式作为程序编译后的存储格式,Java虚拟机提供的语言无关性是指虚拟机不关心Class的来源是何种语言,只要能生成Class文件就够了.
    • 可以使用Binary Viewer读取二进制文件

5.1 字节码格式

  • Java Class文件

    • 8位字节的二进制流,数据项按顺序存储在class文件中,相邻的项之间没有间隔,这样可以使class文件紧凑,占据多个字节的空间的项按照高位在前的顺序分为几个连续的字节存放,在class文件中
    • 可变长度项的大小和长度位于实际数据之前,这个特性使得class文件流可以从头到尾被顺序解析,首先读出项大小,然后读出项数据
    • class文件中有两种数据结构,可对比xml或json

      • 无符号数
    • 二进制文件没有空格和换行,节省空间和提高性能,但放弃了可读性

深入理解Java虚拟机_第16张图片

  1. 魔数

    • 每个Java Class文件的前4个字节被称为它的魔数(magic number):0xCAFEBABE
    • 魔数的作用在于,可以轻松的分辨出Java Class文件和非Java Class文件
    • class文件的下面4个字节包含了主,次版本号,对于Java虚拟机来说,版本号确定了特定的class文件格式,通常只有给定主版本号和一系列次版本号后,Java虚拟机才能够读取class文件,如52对应JDK1.8
  2. 常量池

    • constant_pool_count:两个字节表示常量池的长度,编号从1开始
    • constant_pool:每个常量池入口都从一个长度为一个字节的标志开始(tag),这个标志指出 了列表中该位置的常量类型,JDK 1.7后共有14种不同的表结构数据

深入理解Java虚拟机_第17张图片

  1. 访问标志access_flags

    • 紧接常量池后的两个字节称为access_flags,它展示了文件中定义的类或接口的几段信息,包括这个Class是类还是接口
    • 是否定义为public类型,是否为abstract类型,在access_flags中所有未使用的位都必须由编译器置0

深入理解Java虚拟机_第18张图片

  1. 类索引

    • 接下来两个字节为this_class项,它是一个对常量池的索引,在this_class位置的常量池入口必须为CONSTANT_Class_info表,该表由两个部分组成-----标签和name_index

      • 标签部分是一个具有CONSTANT_Class值的常量
      • name_index位置的常量池入口为一个包含了类或接口的全限定名的CONSTANT_Utf8_info
    • 父类索引与接口索引集合同理

深入理解Java虚拟机_第19张图片

  1. 字段表集合

    • 在interfaces后面的是对类或接口中所声明的字段的描述,首先是field_count的计数,它是类变量和实例变量的字段的数量总和,在这个计数后面是不同长度的field_info表的序列(不包括从超类继承的字段)

      深入理解Java虚拟机_第20张图片

  2. 方法表集合

    • 紧接fields后面的是对该类或接口中所声明的方法的描述,只包括在该类或接口中显式定义的方法

      深入理解Java虚拟机_第21张图片

  3. 属性表集合

    • 在Class文件,字段表,方法表中都可以携带自己的属性集合

      • 相对其他表,属性表的限制相对较小,不再要求各个属性表之间有严格的顺序,可以写入自定义的属性信息,JVM定义了21项属性表
      • 每个属性,它的名称都需要从常量池中引入一个Constant_Utf8_info类型的常量表示,而属性值完全自定义,只需要一个u4的长度属性去说明属性值所占用的位数即可
 ![image.png](/img/bVbHxOk)

5.2 字节码指令

  1. 加载和存储指令

    • 加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输

      • 将一个局部变量加载到操作数栈

        • iload,lload,fload,dload,aload
      • 将一个数值从操作数栈存储到局部变量表

        • istore,lstore,fstore,dstore,astore
      • 将一个常量加载到操作数栈

        • bipush:将一个byte类型数据入栈
        • ldc:从运行时常量池中提取数据压入操作数栈
  2. 运算指令

    • 运算指令用于对两个操作数栈上的值进行某种运算,并把结果重新存储到操作数栈顶,运算指令主要分为:整型数据运算和浮点型数据运算,其中以下x=i,l,f,d分别表示int型,long型,float型,double型

      • 加法指令:xadd
      • 减法指令:xsub
      • 乘法指令:xmul
      • 除法指令:xdiv
      • 求余指令:xrem
      • 取反指令:xneg
      • 位移指令:xshl,xshr,xushr
      • 按位或指令:xor
      • 按位于指令:xand
      • 按位异或指令:xxor
      • 局部变量自增指令:xinc
      • 比较指令:xcmpl
  3. 类型转换指令

    • 类型转换指令可以将两种不同数值类型进行互相转换,Java虚拟机直接支持(无需转换指令)宽化类型转换(小范围向大范围类型的安全转换)

      • int---long---float---double
    • 处理窄化类型转换时,需要使用相关指令

      • i2b,i2c,i2s,d2i
  4. 对象创建和访问指令

    • 在java中类实例和数组都是对象,但是JVM对类Class对象和数组对象的创建使用了不同的字节码指令

      • 创建类实例的指令:new
      • 创建数组的指令:newarray
      • 访问类变量(static字段)的指令:getstatic,putstatic
      • 访问实例变量的指令:getfield,putfield
      • 将一个数组元素加载到操作数栈的指令:baload,caload
      • 将一个操作数栈的值存到数组元素中的指令:bastore,castore
      • 取数组长度的指令:arraylength
      • 检查类实例类型的指令:instanceof,checkcast
  5. 操作数栈管理指令

    • 将操作数栈栈顶n个元素出栈:pop(n)
    • 复制栈顶1个或2个数值,并将复制的值重新压入栈顶:dup,dup2
    • 将栈顶两个数值互换:swap
  6. 控制转移指令

    • 控制转移指令可以让Java虚拟机从指定位置的指令继续执行(而不是当前指令的下一条),所以从概念模型上理解,可以认为控制转移指令就是有条件和无条件修改PC寄存器的值

      • 条件分支:ifeq,iflf,ifgt
      • 复合条件分支:tableswitch,lookupswitch
      • 无条件分支:goto
  7. 方法调用指令和返回指令

    • 方法调用指令(分派,执行过程)

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

      • 方法调用指令跟数据类型无关,方法返回指令是根据返回值类型区分的

        • return:提供声明为void的方法,实例初始化方法,类和接口的类初始化方法使用
        • ireturn:提供int类型的数据,当返回值是boolean,byte,char,shot和int时使用
        • 其他类型的返回指令:lreturn,freturn,dreturn,areturn
  8. 异常处理指令

    • 在java程序中显示抛出异常的操作(如throw语句)都由athrow指令来实现,除了用throw语句显示抛出的异常外,java虚拟机还规定了许多字JVM检查到异常时自动抛出的运行时异常
    • java虚拟机中处理异常(如catch语句)不是由字节码指令实现的,而是采用异常处理器(异常表)完成的
  9. 同步指令

    • java虚拟机可以支持方法级的同步和方法内部一段指令的同步,两者同步都是使用管程(Monitor)来支持的

      • 方法级的同步

        • 方法级的同步是隐式的,即无需通过字节码指令控制,它实现在方法调用和返回操作之中,虚拟机可以从方法常量池的方法表中ACC_SYNCHRONIZED访问标志得知此方法是否声明为同步方法,
        • 当方法调用时,如果此方法为同步方法,则执行线程就要去先成功持有管程,然后才能执行方法,方法(无论成功与否)完成后释放管程,如果这个同步方法执行期间抛出异常,并且方法内部无法处理,那么方法持有的管程将在异常抛出去后自动释放
      • 指令序列级的同步

        • 同步一段指令序列通常由Java中的synchronized语句块来表示的,Java虚拟机指令集中有monitorenter和monitorexit两条指令来支持synchronized关键字

六. 类加载机制

6.1 类加载的生命周期

  1. 类加载的时机

    深入理解Java虚拟机_第22张图片

    • 加载-验证-准备-初始化-卸载这五个阶段的顺序是确定的,但解析阶段则不一定,它在某些情况下可以在初始化阶段之后进行,虚拟机规范严格规定了有且只有5种情况必须对类进行"初始化"

      1. 使用new关键字实例化对象的时候,读取或设置一个类的静态字段的时候,已经调用一个类的静态方法的时候
      2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有初始化,则需要先触发其初始化
      3. 当初始化一个类的时候,如果发现其父类没有初始化就会先初始化它的父类
      4. 当虚拟机启动的时候,用户需要指定一个要执行的任务(就是包含main()方法的那个类),虚拟机会先初始化这个类
      5. 使用jdk 1.7动态语言支持的时候的一些情况
    • 除以上五种之外的引用类的方式都不会触发初始化,称为被动引用

      1. 通过子类引用父类静态字段,不会导致子类初始化
      2. 通过数组定义引用类,不会触发此类的初始化
      3. 常量在编译器阶段会存入调用类的常量池,本质上没有直接引用定义常量的类,因此不会触发定义常量的类的初始化
  2. 加载

    • 加载过程

      1. 通过类的完全限定名,产生一个代表该类型的二进制数据流

        1. 文件(Class文件,Jar文件)
        2. 网络
        3. 计算机生成(代理Proxy)
        4. 由其他文件生成(jsp)
        5. 数据库
      2. 解析这个二进制流为方法区内的运行时数据结构
      3. 创建一个表示该类型的java.lang.Class类的实例,作为方法区这个类的各种数据的访问路口
  3. 验证

    • 验证是连接阶段的第一步,这一阶段的目的就是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全
    • 从整体上看,验证阶段大致上会完成4个阶段的校验工作

      1. 文件格式

        • 是否以魔数0xCAFEBABE开头
        • 主,次版本号是否在当前虚拟机的处理范围内
        • 常量池的常量是否有不被支持的类型
        • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
        • CONSTANT_Utf8_info型的常量是否有不符合UTF8编码的数据
        • Class文件中各个部分文件本身是否有被删除的附加信息
      2. 元数据

        • 这个类是否有父类(除了Object外)
        • 这个类的父类是否继承了不允许被继承的类(final修饰的类)
        • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
        • 类中的字段,方法是否与父类中的产生矛盾(覆盖父类final字段,出现不符合规范的虫重载)
      3. 字节码

        • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作(不会出现按long类型读取一个int数据)
        • 保证跳转指令不会跳转到方法体以外的字节码指令上
        • 保证方法体中的类型转换是有效的(子类对象复制给父类数据类型是安全的,反之不合法)
      4. 符号引用

        • 符号引用中通过字符串描述的全限定名是否能找到对应的类
        • 符号引用中的类,字段,方法的访问性
  4. 准备

    • 准备阶段正式为类变量分配内存并设置类变量初始值,这些变量的内存在方法区中分配(含static修饰的变量不含实例变量)

      • 如:publci static int value = 1122; 这句代码在初始值设置之后为0,因为这时候并未开始执行任何java方法,而把value复制为1122的putstatic指令是程序被编译后,存放于clinit()方法中,所以初始化阶段才会对value赋值
  5. 解析

    • 这个阶段是虚拟机将常量池内的符号引用替换为直接引用的过程

      • 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,符号引用与虚拟机实现的内部布局无关,引用的目标并不一定已经加载到了内存中
      • 直接信用:直接引用可以使直接指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄,直接引用和快速的内存布局有关,引用的目标必定已经加载到了内存中
  6. 初始化

    • 前面过程都以虚拟机为主导,而初始化阶段开始执行类的java代码

      • 初始化阶段是执行构造器()方法的过程,它是由编译器自动收集类中的所有类变量的赋值动作和静态语句块static{}中语句合并产生.静态语句块中只能访问定义在静态语句块之前的变量
      • 父类中定义的静态语句块要由于子类的变量赋值操作
      • 如果一个类没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类产生()方法
      • 虚拟机会保证一个类的()方法在多线程的环境中被正确的加锁,同步,如果多线程同时去初始化一个类,线程去执行这个类的()方法,其他的线程都需要阻塞等待,直到活动线程的()方法结束

6.2 类加载和对象创建流程

  1. 启动JVM,开始分配内存空间
  2. 开始加载Test.class文件,加载到方法区中,在加载的过程中静态的内容要进入静态区中
  3. 在开始运行main方法,这时JVM就会把main调用到栈中运行,开始从方法的第一行往下执行
  4. 在main方法中new Child(); 这时JVM就会在方法区中查找有没有Child文件,如果没有就加载Child.class文件,如果Child继承Parent类,那么也需要查找有没有Parent文件,如果没有也需要加载Parent.class文件
  5. Child.class和Parent.class中所有的非静态内容会加载到非静态的区域中,而静态的内容会加载到静态区中,静态内容(静态变量,静态代码块,静态方法)

    • 类的加载只会执行一次,下次再创建对象时,可以直接在方法区中获取class信息
  6. 开始给静态区中的所有静态的成员变量开始分配内存和默认初始值
  7. 之后给所有的静态成员变量显示初始化和执行静态代码块---()方法

    • 静态代码块时在类加载的时候执行的,类的加载只会执行一次所以静态代码块也只会执行一次
    • 非静态代码块和构造函数中的代码是在对象创建的时候执行的,因此对象创建(new)一次,它们就会执行一次
  8. 这时Parent.class文件和Child.class文件加载完成
  9. 开始在堆中创建Child对象,给Child对象分配内存空间,其实就是分配内存地址
  10. 开始对类中的非静态的成员变量开始默认初始化
  11. 开始加载对应的构造方法,执行隐式三步

    1. 隐式的super()
    2. 显示初始化(给所有的非静态成员变量)
    3. 执行构造代码块
    4. 执行本类的构造方法
  12. 对象创建完成,把内存的地址赋值给引用对象使用
  13. 如果后续又创建(new)一个新的Child对象,重复步骤9之后的步骤

七. 字节码执行引擎

  1. 运行时的栈帧结构

    • 栈帧存储了方法的局部变量表,操作数栈,动态链接和方法返回地址等信息,每一个方法从调用开始到执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程

      深入理解Java虚拟机_第23张图片

  2. 局部变量表

    • 局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量,局部变量表的容量以变量槽(Variable Slot)为最小单位,一个Slot可以存放一个32位以内的数据类型
    • reference类型表示一个对象实例的引用,对于 64位的数据类型(long和double),虚拟机会以高位对齐的方式为其分配两个连续的Slot空间,是线程安全的
  3. 操作数栈

    • 是一个后入先出栈,当一个方法执行开始时,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作
  4. 动态链接

    • 每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这引用是为了支持方法调用过程中的动态链接
  5. 方法返回地址

    • 方法退出的过程实际上等同把当前栈出栈,因此退出时可能执行的操作有:

      • 恢复上层方法的局部变量表和操作数栈
      • 把返回值(如果有的话)压入调用者栈帧的操作数栈中,调用PC计数器的值以指向方法调用指令后面的一条指令
  6. 方法调用---解析

    • 方法调用并不等同于方法的执行,方法调用阶段的唯一任务就是确定被调用方法的版本(继承和多态)
    • "编译期可知,运行期不可变"的方法(静态方法和私有方法),在类加载的解析阶段,会将其符号引用转化为直接引用(入口地址),这类方法的调用称为解析
  7. 方法调用---分派

    • 静态分派最典型的应用就是方法重载
    • 在运行期根据实际类型确定方法执行版本的分派过程称为动态分派,最典型的应用就是方法重写

八. 类加载器

8.1 JVM类加载器

深入理解Java虚拟机_第24张图片

  1. Java虚拟机自带了以下几种加载器

    1. 根(Bootstrap)类加载器

      • 该加载器没有父加载器,它负责加载虚拟机的核心类库,如:java.lang.*等,根类加载器从系统属性sun.boot.class.path所指定的目录中加载类库,根类加载器的实现依赖于底层操作系统,属于虚拟机的实现的一部分,它并没有继承java.lang.ClassLoader类
    2. 扩展(Extension)类加载器

      • 它的父加载器为根类加载器,它从java.ext.dirs系统属性所指定的目录加载类库,或从JDK安装目录jre/lib/ext子目录下加载类库,如果把用户创建的JAR文件放在这个目录下,也会自动由扩展类加载器加载,扩展类加载器是纯Java类,是java.lang.ClassLoader类的子类
    3. 系统(System)类加载器

      • 也称为应用类加载器,它的父加载器为扩展类加载器,它从环境变量classpath或系统属性java.class.path所指定的目录中加载类,它是用户自定义的类加载器的默认父加载器,系统类的加载器是纯Java类,是java.lang.ClassLoader类的子类
  2. 父委托机制案例:

    1. 当自定义一个java.lang.String类的时候,当主动使用自定义String类,实例化的是java定义的String类
    2. 这是因为当类加载器区初始化类时,会一层一层往上委托,先由Bootstrap类加载器区初始化,若找不到再由Extendsion类加载器去初始化,最后都找不到字节码文件时,再由应用加载器去初始化
    3. 若class存在classpath系统路径中,那么就会由系统类加载器去初始化,不能由我们自己定义的加载器初始化,只有在classpath下不存在才能使用自定义类加载器

      public static void main(String[] args){
          Class clazz = Class.forName("java.lang.String");
          System.out.print(clazz.getClassLoader); //null
      }
      
      public class String{
          
          static {
              System.out.println("my custom String class");
          } 
      }
      • 所以在加载String类时使用的是Bootstrap类加载器,若自定义的类加载器优先级更高,那么继承这个类的所有类都会受到影响,所以父委托机制就避免了这种安全性问题
      • 父委托机制的优点:能提高提系统的安全性,在此机制下,用户自定义的类加载器不可能加载应该由父加载器加加载的可靠类,因此可以防止恶意代码替代父加载器的可靠代码
      • 父子类加载器之间的真实关系---包装关系

8.2 自定义类加载器

  • 自定义类加载器

    • 使用defineClass()方法
    • 重写findClass()方法
    • 对外调用loadClass()方法
    public class MyClassLoader extends ClassLoader{
        private final static String DEFAULT_DIR = "D:\\classloader";
        private String dir = DEFAULT_DIR;
        private String classLoaderName;
        
        public MyClassLoader(){
            super();
        }
        
        public MyClassLoader(String classLoaderName){
            super();
            this.classLoaderName = classLoaderName;
        }
        
        public MyClassLoadeR(String classLoaderName,ClassLoader classLoader){
            super(classLoader);
            this.classLoaderName = classLoaderName;
        }
        
        @Override
        protected Class findClass(String name) throw ClassNotFoundException{
            String classPath = name.replace(".","/");
            File classFile = new File(dir,classPath + ".class");
            if(!classFile.exists()){
                throw new ClassNotFoundException("the class" + name + "not found");
            }
            
            byte[] classBytes = loadClassBytes(classFile);
            if(null == classBytes || classBytes.length == 0){
                throw new ClassNotFoundException("the class" + name + "load failed");
            }
            
            return this.defineClass(name,classBytes,0,classBytes.length);
        }
        
        private byte[] loadClassBytes(File file){
            try(ByteArrayOutputStream bos = new ByteArrayOutputStream();
                FileInputStream fis = new FileInputStream(file)){
                byte[] buffer = new byte[1024];
                int len;
                while((len = fis.read(buffer)) != -1){
                    bos.write(buffer,0,len);
                }
                bos.flush();
                return bos.toByteArray();
            }catch(IOException e){
                e.printStackTrace();
                return null;
            }
        }
    }
    
    --------------------------
        publi static void main(String[] args){
        MyClassLoader loader = new MyClassLoader("loader1");
        load1.setDir("D:\\classloader");
        Class aclass = load1.loadClass("com.lsy.Demo1");
        System.out.println(aclass);
        System.out.println((MyClassLoader)aclass.getClassLoader().getClassLoaderName());
    }
    1. loadClass():是加载 类名.class字节码文件的工具
    2. findClass():是类加载器在JVM内部实现查找指定路径下.class文件的机制

      1. Bootstrap---Ext---App,按照这个顺序进行查找
      2. 而自定义类加载器就是复写了该方法,将指定目录下的字节码文件,通过ByteArrayOutputStream解密后的字节码文件给JVM去加载
    3. defineClass():是将你定义的字节码文件经过字节数组流解密后,将该字节数组流生成字节码对象,也就是该类的 类名.class
    • loadClass():判断是否已加载,使用双亲委派机制,请求父加载器,使用findClass()

      • finaClass():过呢局名称和位置加载.class字节码的,使用defineClass()方法

        • defineClass():解析定义.class字节流,返回class对象

8.3 打破双亲委托机制

  • 自定义一个类加载器,在类加载器中同时重写loadClass()方法和findClass()方法,外界调用loadClass()方法不是从父类继承来的,而实子类自己的
@Override
protexted Class loadClass(String name,boolean resolve) throw ClassNotFoundException{
    Class clazz = null;
    
    if(name.startWith("java.")){
        try{
            ClassLoader system = ClassLoader.getSystemClassLoader();
            clazz = System.loadClass(name);
            if(clazz != null){
                if(resolve){
                    resolveClass(clazz);
                }
                return clazz;
            }
        }catch(Exception e){
            e.printStackTrace();
            return null;
        }
    }
    
    try{
        clazz = findClass(name);
    }catch(Exception e){
        
    }
    if(clazz == null && getParent() != null){
        getParent().loadClass(name);
    }
    
    return clazz;
}
  • 注:

    1. 但是对于java.lang包下的类仍然是不能自定义的,因为此包下的类是不允许重名的,所以想自定义java.lang.String来测试双亲委派机制是不行的

8.4 名称空间和运行时包

  1. 命名空间

    • 类加载器的命名空间

      1. 每个类的加载器都有自己的命名空间,命名空间由该加载器及其所有父加载器所加载的类组成
      2. 在同一个命名空间中,不会出现完成的名字
      //Boot.Ext.App.SimpleClassLoader.com.lsy.Demo
  2. 运行时包

    • 运行时包 = 命名空间 + 包名 + 类名

      1. 父类加载器看不到子类加载器加载的类
      2. 子加载器加载的类可以看到父加载器加载的类
      3. 不同命名空间下的类加载器之间的类互相不可访问
  3. 类的写案子及ClassLoader的卸载

    • JVM中的Class只有满足以下三个条件,才能被GC回收,也就是该Class被卸载(unload)

      1. 该类所有的实例都已经被GC
      2. 加载该类的ClassLoader实例已经被GC
      3. 该类的java.lang.Class对象没有在任何地方被引用
    • GC的时机我们是不可控的,同样对于Class的卸载也是不可控的
    • 实例对象 ----> ClassLoader -----> Class对象

      • 在Class中有一个ClassLoader的引用,所以需要先回收ClassLoader
  4. 注:

    1. 当一个已经被加载的类是无法被更新的,如果试图用用一个ClassLoader再次加载同一个类,就会得到duplicate classdefinition Exception,我们之恶能够重新创建一个新的ClassLoader实例来再次加载新类,至于原来已经加载的类就不需要管它了,因为它可能还有其他案例正在使用,只要相关的实例都被回收,那么JVM就会在适当的时机把类加载器卸载
    2. 如何实现一个工程中不同模块加载不同版本的同名JAR包?

      1. 在JVM里由类名和类加载器区别不同的Java类型,因此,JVM允许我们使用不同的加载器加载相同namespace的java类,而实际上这些相同namespace的java类可以是完全不同的类
      2. 通常我们都使用默认的类加载器,所以同步类或者同名jar包是唯一的,无法加载同名jar包的不同版本,而在JVM里不同的类加载器可以加载相同namespace的java类

8.5 自定义加密解密加载器

public final class EncryptUtils{
    private static final byte ENCRYPT_FACTOR = (byte) 0xff;
    
    private EncryptUtils(){
        //empty...
    }
    
    public static void doEncrypt(String source,String target){
        try(FileInputStream fis = new FileInputStream(source);
            FileOutputStream fos = new FileOutputStream(target)){
            int data;
            while((data = fis.read()) != -1){
                fos.write(data ^ ENCRYPT_FACTOR);
            }
            fos.flush();
        }catch(Exception e){
            e.printStackTrace();
        }
    }
}
----------------------------------------
 
    private byte[] loadClassBytes(File file){
        try(ByteArrayOutputStream bos = new ByteArrayOutputStream();
            FileInputStream fis = new FileInputStream(file)){
            int data;
            while((data = fis.read()) != -1){
                bos.write(data ^ EncryptUtils.ENCRYPT_FACTOR);
            }
            bos.flush();
            return bos.toByteArray();
        }catch(IOException e){
            e.printStackTrace();
            return null;
        }
    }

你可能感兴趣的:(java,jvm)