Java随笔(6):JVM的梳理记录

转载请注意:http://blog.csdn.net/wjzj000/article/details/76641519

本菜开源的一个自己写的Demo,希望能给Androider们有所帮助,水平有限,见谅见谅…
https://github.com/zhiaixinyang/PersonalCollect (拆解GitHub上的优秀框架于一体,全部拆离不含任何额外的库导入)
https://github.com/zhiaixinyang/MyFirstApp(Retrofit+RxJava+MVP)
以及一个可以依赖的自定义验证码View:
https://github.com/zhiaixinyang/VerifyCodeView


写在前面

这是踏入八月的第一篇博客,也是实习后的第一篇博客。多多少少有一些情感掺杂于其中。
这是一篇关于JVM的博客,自己对于JVM的认识,也一直停留在知识点的层面上,这一次综合的学习记录一番,也算是对自己基础知识上的缝补。


开始

编译

首先,一个java类想要被运行,它就要接受编译的过程。最直接的方式命令行javac,回车敲下的一瞬间,我们写下的.java文件便被编译成.class文件,也就是我们的字节码文件。

加载

当我们紧接着执行运行.class文件的命令后,系统就会启动一个JVM进程,JVM进程从classpath路径中找到.class的二进制文件,将这个类的类信息加载(加载的过程使用到了ClassLoader)到运行时数据区的方法区内,这个过程叫做类的加载。

将class文件字节码内容加载到内存中,并将这些静态数据转换成方法区中的运行时数据结构,在中生成一个代表这个类的java.lang.Class对象,作为方法区类数据的访问入口,这个过程需要类加载器参与。

在段话中加粗了运行时数据区的方法区,这里边设计到了JVM的运行时数据区的问题。也就是我们常提到了:堆,方法区,虚拟机栈,程序计数器等(这里先按住不表,后面我们在娓娓道来)。


紧接着JVM寻找我们的main方法(会在虚拟机栈中的为这个main方法创建栈帧…),进入后开始执行main方法中的语句,如果这里我们使用了new,就是让JVM创建一个对应的对象,如果这时候方法区中没有这个类的信息,那么JVM马上加载这个类,把此类的类型信息放到方法区中。

加载完这个类之后,JVM立即在区中为一个新的实例分配内存, 然后调用构造方法初始化这个实例,这个实例持有着指向方法区的类的类型信息的引用。

此时如果我们通过这个实例的引用调用了它的某个方法,那么JVM根据这个实例的引用找到这个对象,然后根据这个对象持有的引用定位到方法区中这个类的类型信息的方法表,获得这个方法的字节码的地址。

然后开始执行这个方法。

完毕

一个普通类的编译加载运行的流程基本就是如此,当然这里的细节并没有展开,因此接下来的篇幅就交由这其中的细节来填充。

简单梳理

(此部分摘自网络:http://www.cnblogs.com/dooor/p/5289994.html

类加载的全过程,经历了:加载链接验证准备解析)、初始化使用卸载

  • 加载:将class文件字节码内容加载到内存中,并将这些静态数据转换成方法区中的运行时数据结构,在堆中生成一个代表这个类的java.lang.Class对象,作为方法区类数据的访问入口,这个过程需要类加载器参与。

  • 链接:将java类的二进制代码合并到JVM的运行状态之中的过程

    • 验证:确保加载的类信息符合JVM规范,没有安全方面的问题
    • 准备:正式为类变量(static变量)分配内存并设置类变量初始值的阶段,这些内存都将在方法去中进行分配
    • 解析:虚拟机常量池的符号引用替换为字节引用过程
  • 初始化

    • 初始化阶段是执行类构造器()方法的过程。类构造器()方法是由编译器自动收藏类中的所有类变量的赋值动作和静态语句块(static块)中的语句合并产生
    • 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
    • 虚拟机会保证一个类的()方法在多线程环境中被正确加锁和同步
    • 当范围一个Java类的静态域时,只有真正声名这个域的类才会被初始化

例子:

public class Demo01 {
    public static void main(String[] args) {
        A a = new A();
        System.out.println(a.width);
    }
}

class A{
    public static int width=100; //静态变量,静态域 field
    static{
        System.out.println("静态初始化类A");
        width = 300 ;
    }
    public A() {
        System.out.println("创建A类的对象");
    }
}

这里写图片描述

1、JVM加载Demo01时候,首先在方法区中形成Demo01类对应静态数据(类变量、类方法、代码…),同时在堆里面也会形成java.lang.Class对象(反射对象),代表Demo01类,通过对象可以访问到类二进制结构。然后加载变量A类信息,同时也会在堆里面形成a对象,代表A类。

2、main方法执行时会在栈里面形成main方法栈帧,一个方法对应一个栈帧。如果main方法调用了别的方法,会在栈里面挨个往里压,main方法里面有个局部变量A类型的a,一开始a值为null,通过new调用类A的构造器,栈里面生成A()方法同时堆里面生成A对象,然后把A对象地址付给栈中的a,此时a拥有A对象地址。

3、当调用A.width时,调用方法区数据。


细节展开

这部分将对,上诉提到的内容进行展开。

运行时数据区

Java堆

在JVM中,堆(heap)是可供各条线程共享的运行时内存区域,也是供所有类实例和数据对象分配内存的区域。
Java堆在JVM启动的时候就被创建,堆中储存了各种对象,这些对象被自动管理内存系统(Garbage Collector(我们常提到的GC))所管理。
Java堆的容量可以是固定大小,也可以随着需求动态扩展,并在不需要过多空间时自动收缩。

Java 堆存在的异常:
OutOfMemoryError:如果实际所需的堆空间超过了自动内存管理系统能提供的最大容量时抛出。


方法区(Method Area)

方法区是可供各条线程共享的运行时内存区域。存储了每一个类的结构信息,例如:运行时常量池、字段和方法数据、构造函数和普通方法的字节码内容、还包括一些在类、实例、接口初始化时用到的特殊方法。
方法区在JVM启动的时候创建。
方法区的容量可以是固定大小的,也可以随着程序执行的需求动态扩展,并在不需要过多空间时自动收缩。

Java 方法区异常:
OutOfMemoryError: 如果方法区的内存空间不能满足内存分配请求,那么JVM将抛出一个OutOfMemoryError异常。

运行时常量池(Runtime Constant Pool):
运行时常量池是每一个类或接口的常量池(Constant_Pool)的运行时表现形式,它包括了若干种常量:编译器可知的数值字面量到必须运行期解析后才能获得的方法或字段的引用。
运行时常量池是方法区的一部分。每一个运行时常量池都分配在JVM的方法区中,在类和接口被加载到JVM后,对应的运行时常量池就被创建。
运行时常量池异常:
OutOfMemoryError:当创建类和接口时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大内存空间后就会抛出OutOfMemoryError。


Java虚拟机栈

Java虚拟机栈是线程私有的。每一个JVM线程都有自己的Java虚拟机栈,这个栈与线程同时创建,它的生命周期与线程相同。
虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

Java虚拟机栈异常情况:
StackOverflowError:当线程请求分配的栈容量超过JVM允许的最大容量时抛出。
OutOfMemoryError:如果JVM Stack可以动态扩展,但是在尝试扩展时无法申请到足够的内存去完成扩展,或者在建立新的线程时没有足够的内存去创建对应的虚拟机栈时抛出。


程序计数器

是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的信号指示器。
每一条JVM线程都有自己的程序计数器。
在任意时刻,一条JVM线程只会执行一个方法的代码。该方法称为该线程的当前方法(Current Method)。
如果该方法是java方法,那程序计数器保存JVM正在执行的字节码指令的地址
如果该方法是native,那程序计数器的值是undefined。
此内存区域是唯一一个在JVM规范中没有规定任何OutOfMemoryError情况的区域。


类加载器以及双亲委派机制

类加载器

类加载器:在类加载阶段,有一步是“通过类的全限定名来获取描述此类的二进制字节流”,而所谓的类加载器就是实现这个功能的一个代码模块,这个动作是在JVM外部实现的,这样做可以让应用程序自己决定如何去获取所需要的类。

作用:首先类加载器可以实现最本质的功能即类的加载动作。同时,它还能够结合Java类本身来确定该类在JVM中的唯一性。用通俗的话来说就是:比较两个类是否相等,只有这两个类是由同一个类加载器加载才有意义。否则,即使这两个类是来源于同一个Class文件,只要加载它们的类加载器不同,那么这两个类必定不相等。(保证了安全性,避免替换核心类的加载)


双亲委派机制

(以下部分内容来自网络:http://chenzhou123520.iteye.com/blog/1601319

大部分Java程序一般会使用到以下三种系统提供的类加载器:

  • 启动类加载器(Bootstrap ClassLoader):负责加载JAVA_HOME\lib目录中并且能被虚拟机识别的类库到JVM内存中,如果名称不符合的类库即使放在lib目录中也不会被加载。该类加载器无法被Java程序直接引用。

  • 扩展类加载器(Extension ClassLoader):该加载器主要是负责加载JAVA_HOME\lib\ext目录中的类库。该加载器可以被开发者直接使用。

  • 应用程序类加载器(Application ClassLoader):该类加载器也称为系统类加载器,它负责加载用户类路径(Classpath)上所指定的类库,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

双亲委派模型:该模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。子类加载器和父类加载器不是以继承(Inheritance)的关系来实现,而是通过组合(Composition)关系来复用父加载器的代码。

工作过程
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的加载器都是如此,因此所有的类加载请求都会传给顶层的启动类加载器,只有当父加载器反馈自己无法完成该加载请求(该加载器的搜索范围中没有找到对应的类)时,子加载器才会尝试自己去加载。

使用这种模型来组织类加载器之间的关系的好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如java.lang.Object类,无论哪个类加载器去加载该类,最终都是由启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。否则的话,如果不使用该模型的话,如果用户自定义一个java.lang.Object类且存放在classpath中,那么系统中将会出现多个Object类,应用程序也会变得很混乱。如果我们自定义一个rt.jar中已有类的同名Java类,会发现JVM可以正常编译,但该类永远无法被加载运行。

代码实现:

protected synchronized Class<?> loadClass(String name, boolean resolve)   throws ClassNotFoundException{  
    // First, check if the class has already been loaded  
    Class c = findLoadedClass(name);  
    if (c == null) {  
        try {  
            if (parent != null) {  
                c = parent.loadClass(name, false);  
            } else {  
                c = findBootstrapClassOrNull(name);  
            }  
        } catch (ClassNotFoundException e) {  
            // ClassNotFoundException thrown if class not found  
            // from the non-null parent class loader  
        }  
        if (c == null) {  
            // If still not found, then invoke findClass in order  
            // to find the class.  
            c = findClass(name);  
        }  
    }  
    if (resolve) {  
        resolveClass(c);  
    }  
    return c;  
}  

先检查是否已经被加载过,如果没有则调用父加载器的loadClass()方法,如果父加载器为空则默认使用启动类加载器作为父加载器。如果父类加载器加载失败,则先抛出ClassNotFoundException,然后再调用自己的findClass()方法进行加载。


JVM垃圾回收机制

根搜索算法(GC Roots Tracing)

用于识别

思想:通过一系列的称为“GC Roots”的点作为起始进行向下搜索,当从GC到某一个对象不可达的时候,也就是说一个对象到GC Roots没有任何引用链相连的时候,这个对象就会被判定为可回收的。

在java中,可作为GC Roots的对象包括下面几种:

  • 1.虚拟机栈(栈帧中的本地变量表)中的引用的对象。
  • 2.方法区中的类静态属性引用的对象。
  • 3.方法区中的常量引用的对象。
  • 4.本地方法栈中JNI(即一般说的Native方法)引用的对象。

用于回收的算法

标记-清除算法:

标记阶段:从GC Roots开始进行可达性分析,将所有可达对象标记出来
清除阶段:将所有未标记可达的对象清理掉
标记-清除算法带来的问题是内存碎片,被清除掉的对象会在内存中留下很多的“洞”,这些“洞”的大小可能不足以容下新创建的对象,随着碎片的增多,内存池中实际可用的内存可能会变得越来越少。

复制算法:

为了解决效率问题,有了“复制”的算法,他将可用内存分为大小相同两块。每次只用一块,当一块空间用完了,就将还存活的对象复制到另一块上,然后将刚使用过的内存空间一次清理掉。这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况。实现简单,运行高效。只是这种算法的代价是将内存缩小到原来的一半。

一般来说:将内存分为一块较大的Eden空间和两块较小的Surivior空间,每次使用Eden空间和其中一块Surivior空间。当回收时,将Eden和Surivior中还存活的对象一次性的拷贝到另一块Surivior中,最后清理掉Eden和刚用过的Survivor空间。

标记-整理算法:

与标记-清除算法类似,区别在于在标记阶段完成后,将所有可达对象移动到内存池的另一端,形成一整块连续的内存空间,然后再将范围外的内存池清空。
标记-压缩算法的好处是不会产生内存碎片,也不会占用冗余的内存空间,但代价是需要付出额外的工作将存活对象移动,效率低于标记-清除算法。

分代收集算法

一般是把Java堆分作新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就用复制算法,只要少量复制成本就可以完成收集。而老年代中因为对象的存活率较高、周期长,就用“标记-整理”或“标记-清除”算法来回收。

简单来说,我们在对处理年轻代的对象时,使用的是复制算法,也就是8:1:1的比例划分出了Eden,Surivior(from),Surivior(to)。每次回收,会使用一块Eden,和一块Surivior,回收不掉的移入另一块Surivior。多次没有被回收将移入老年代。
对于老年代将采取标记-整理算法进行回收。

新生代使用复制算法,当Minor GC时如果存活对象过多,无法完全放入Survivor区,就会向老年代借用内存存放对象,以完成Minor GC。

在触发Minor GC时,虚拟机会先检测之前GC时租借的老年代内存的平均大小是否大于老年代的剩余内存,如果大于,则将Minor GC变为一次Full GC,如果小于,则查看虚拟机是否允许担保失败,如果允许担保失败,则只执行一次Minor GC,否则也要将Minor GC变为一次Full GC。


尾声

OK,JVM的部分到此就算是记录完毕。

最后希望各位看官可以star我的GitHub,三叩九拜,满地打滚求star:
https://github.com/zhiaixinyang/PersonalCollect
https://github.com/zhiaixinyang/MyFirstApp

猜你喜欢

转载自blog.csdn.net/wjzj000/article/details/76641519