JVM初探(一):双亲委派机制

Posted 真香号

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM初探(一):双亲委派机制相关的知识,希望对你有一定的参考价值。


目录

一、JVM初探

JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM 是一种用于计算机设备的规范,它是一个虚构出来的计算机,是通过在实际计算机上仿真模拟各种计算机功能来实现的。

引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在虚拟机上运行的字节码,就可以在多种平台上不加修改的运行。

1.1 JVM的位置

1.2 JVM体系结构

本地方法接口:JNI(Java Native Interface)


二、双亲委派机制

在介绍双亲委派机制前先介绍一下 类加载器

2.1 类加载器

作用:加载Class文件


Java是运行是通过Java的虚拟机(JVM)的,但是它是如何运行在JVM中了呢?我们第一天学Java 就知道,编写的Java源代码需要被编译器编译成.class的字节码文件,然后再通过解释器,电脑才能执行。然后由我们得ClassLoader负责将这些class文件给加载到JVM中去执行。
JVM中提供了三层的ClassLoader:

1. 虚拟机自带的加载器
2. 启动类(根) 加载器【BOOTStrapClassLoader】: 主要负责加载核心的类库(java.lang.*等),构造ExtClassLoader和APPClassLoader。
3. 扩展类加载器 【EXTClassLoader】: 主要负责加载jre/lib/ext目录下的一些扩展的jar。
4. 应用程序加载器【AppClassLoader】: 主要负责加载应用程序的主函数类

:加载过程 从 4到1进行加载

看一下JDK 8 中的双亲委派是怎么实现的
搜索ClassLoader 类

看到 loadClass 方法

    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    
        synchronized (getClassLoadingLock(name)) 
            // 首先检查类是否被加载过
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) 
                long t0 = System.nanoTime();
                try 
                   // 如果父类加载器存在 就是使用父类加载器,否则使用Boot根加载器
                    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) 
                    //  如果仍未找到,则调用 findClass 以查找该类。
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    c = findClass(name);

                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                
            
            // 如果已经加载过,就直接解析
            if (resolve) 
                resolveClass(c);
            
            return c;
        
    

解释:双亲委派机制(安全):APP—>EXT—>BOOT【最终执行】

类加载器收到类加载的请求
将这个请求向上委托为父类加载器去完成,一直向上委托,直到启动类加载器
启动类加载器检查是否能够加载当前的这个类,能加载就结束,使用当前的加载器,否则抛出异常,通知子加载器进行加载
重复步骤 3

但是BOOT根加载器输出是null,这是java调用不到~ C,C++ Java = C+±- :去掉繁琐的东西(指针、内存管理等)

举例:我自己建立一个 java.lang.String 类,写上 static 代码块

package java.lang;

public class String 
    static
        System.out.println("我是自定义的String类的静态代码块");
    


在另外的程序中加载 String 类,看看加载的 String 类是 JDK 自带的 String 类,还是我们自己编写的 String 类

public class StringTest 

    public static void main(String[] args) 
        java.lang.String str = new java.lang.String();
        System.out.println("hello,atguigu.com");

        StringTest test = new StringTest();
        System.out.println(test.getClass().getClassLoader());
    

程序并没有输出我们静态代码块中的内容,可见仍然加载的是 JDK 自带的 String 类

为什么呢?
由于我们定义的String类本应用系统类加载器,但它并不会自己先加载,而是把这个请求委托给父类的加载器去执行,到了扩展类加载器发现String类不归自己管,再委托给父类加载器(引导类加载器),这时发现是java.lang包,这事就归引导类加载器管,所以加载的是 JDK 自带的 String 类

/**
 * 双亲委派机制
 */
public class Car 
    public static void main(String[] args) 
        Car car1 = new Car();
        Car car2 = new Car();
        Car car3 = new Car();

        //不同的实例
        System.out.println("car1 hashCode="+car1.hashCode());
        System.out.println("car2 hashCode="+car2.hashCode());
        System.out.println("car3 hashCode="+car3.hashCode());

        //同一个类模版
        Class<? extends Car> aClass1 = car1.getClass();
        Class<? extends Car> aClass2 = car2.getClass();
        Class<? extends Car> aClass3 = car3.getClass();

        System.out.println("aClass1 hashCode="+aClass1.hashCode());
        System.out.println("aClass2 hashCode="+aClass2.hashCode());
        System.out.println("aClass3 hashCode="+aClass3.hashCode());

        //由于类模版都是一个,以下就选择一个进行测试
        ClassLoader classLoader = aClass1.getClassLoader();

        System.out.println(classLoader);  //AppClassLoader

        System.out.println(classLoader.getParent());  //ExtClassLoader  所在位置:\\jre\\lib\\ext

        System.out.println(classLoader.getParent().getParent());  //null 1.不存在  2.java程序获取不到  所在位置:rt.jar

    

运行测试结果:

2.2 面试问题

为什么需要双亲委派机制?(也就是双亲委派的优点):

  • 双亲委派机制使得类加载出现层级,父类加载器加载过的类,子类加载器不会重复加载,可以防止类重复加载;
  • 使得类的加载出现优先级,防止了核心API被篡改,提升了安全,所以越基础的类就会越上层进行加载,反而一般自己的写的类,就会在应用程序加载器(Application)直接加载。

如何打破双亲委派?

  • 自定义类加载器,重写loadClass方法
  • 使用线程上下文类加载器

三、沙箱安全机制

3.1 什么是沙箱

沙箱是一个限制程序运行的环境。沙箱机制就是将 Java 代码限定在虚拟机(JVM)特定的运行范围中,并且严格限制代码对本地系统资源访问,通过这样的措施来保证对代码的有效隔离,防止对本地系统造成破坏。沙箱主要限制系统资源访问,那系统资源包括什么?——CPU、内存、文件系统、网络。不同级别的沙箱对这些资源访问的限制也可以不一样。

​ 所有的Java程序运行都可以指定沙箱,可以定制安全策略。

3.2 组成沙箱的基本条件
  • 字节码校验器 (bytecode verifier): 确保Java类文件遵循Java语言规范。这样可以帮助Java程序实现内存保护。但并不是所有的类文件都会经过字节码校验,比如核心类。
  • 类装载器(class loader): 其中类装载器在3个方面对Java沙箱起作用
    1、它防止恶意代码去干涉善意的代码;
    2、它守护了被信任的类库边界;
    3、它将代码归入保护域,确定了代码可以进行哪些操作。

虚拟机为不同的类加载器载入的类提供不同的命名空间,命名空间由一系列唯一的名称组成,每一个被装载的类将有一个名字,这个命名空间是由Java虚拟机为每一个类装载器维护的,它们互相之间甚至不可见。

类装载器采用的机制是双亲委派模式

1、从最内层JVM自带类加载器开始加载,外层恶意同名类得不到加载从而无法使用;
2、由于严格通过包来区分了访问域,外层恶意的类通过内置代码也无法获得权限访问到内层类,破坏代码就自然无法生效。

  • 存取控制器(access controller): 存取控制器可以控制核心API对操作系统的存取权限,而这个控制策略的设定,可以由用户自己设定。
  • 安全管理器(security manager): 是核心API 和操作系统之间的主要接口。实现权限控制,比存取控制器优先级高。
  • 安全软件包(security package): java.security 下的类和扩展包下的类,运行用户未自己的应用增加新的安全特性,包括:
    1、安全提供者
    2、消息摘要
    3、数字签名
    4、加密
    5、鉴别

JVM之JVM初探

JVM

执行文件

JVM执行的文件为class文件,这个执行文件是什么意思呢,就是虚拟机能够识别的文件,类加载器加载链接初始化后将数据保存在JVM运行时数据区中的文件。

类装入子系统

JVM的类加载器为ClassLoader采用双亲委派模型机制进行加载类。
双亲委派模型机制:
根据父子关系一直往顶层找是否被其他父级parent类加载器加载过,如果找到加载过,返回;如果没有找到,在返回一个一个查找是否有加载的权限,如果有就加载;如果这个时候所有的父级parent类加载器都没有加载过而且没有权限加载,那么自己去加载。

注意:父级parent类加载器不是继承的父子关系,而是一个ClassLoader中的一个parent变量代表父级类加载器。

执行引擎

Java的执行引擎包括解释器,JIT即时编译器,垃圾回收器。解释器不用介绍了,将Java字节码指令翻译为机器能够识别的指令;JIT即时编译器是因为光靠解释运行的效率低,所以对于热点代码进行编译为机器指令以加快运行效率(刚开启的时候得先需要通过解释器解释运行,当符合热点代码的特征时,会直接编译成本地代码之后热点代码就可以直接运行不用在经过解释再运行了。即解释器先运行一段时间才能够真正提升效率);
垃圾回收器,Java能够流行的一个原因还有这个特点:他不用管理内存,由JVM的垃圾回收器自动进行回收垃圾,达到管理内存的目的。

垃圾回收器

JVM运行时内存结构为:PC程序计数器、本地方法栈、虚拟机栈、堆、方法区。其中程序计数器,本地方法栈、虚拟机栈等都是线程私有的,也就是每条线程都拥有这三块区域,而且会随着线程的创建而创建,线程的结束而销毁,因此这三个区域不需要垃圾回收。

堆中的内存回收

这里讲解下垃圾回收器如何判定一个对象是否为垃圾的算法:

1.引用计数器:

之前Java中判断垃圾的算法使用引用计数器进行判定的,对象中有一个字段保存着计数器,只要这个对象被引用了那么计数器就加1;释放了这个引用就计数器减1。垃圾回收器回收的时候看这个对象的引用是否为0,如果为0那么代表这个对象是垃圾,该被回收。

即A引用B,假如这个时候没有其他引用指向A,那么A的计数器为0,B的计数器是1;当A把B的引用释放之后,B也为0,下次垃圾回收的时候就会将A,B回收。

这么看好像也没有问题,但是如果我的两个对象互相引用对方,A要销毁依赖于B,而B销毁依赖于A,这个时候垃圾回收器就不会回收这两个对象,也就是无法解决循环引用的问题。

举例:创建A对象a,创建B对象b。
这个时候A的成员变量引用b,B的成员变量引用a。即a和b的计数器都为1,销毁a的时候发现b在引用a,销毁b的时候发现a在引用b。垃圾回收的时候就不会回收这两个对象,但是除此之外没有其他引用指向这两个对象。

循环引用会导致即使外界已经没有任何指针能够访问他们了,但是他们所占资源仍然无法释放的情况。
优点:不需要STW,找到计数器为0的对象直接进行清除
缺点:维护计数器空间和时间上都有所牺牲,而且无法解决循环引用。

2.GC Root(根可达算法)
上面的算法无法解决循环引用问题,如果这个时候规定一些永远不会被回收的对象只要能被这些对象引用,那么就不是垃圾,这个时候就是GC Root的算法。

一个对象只要一直往上找最终能找到GC Root那么就不是垃圾,如果找不到就是垃圾。这个向上找的链叫做引用链,和GcRoot没有任何链接的称为“垃圾”

**一般而言可以作为Gc Root根节点的有:方法区静态信息,方法区常量信息,Java虚拟机栈所引用的对象,本地方法栈所引用对象。 **

方法区中的内存回收

方法区用于存储已被虚拟机加载的类型信息,常量,静态变量,被即时编译器编译后的代码缓存。
也就是存放类型信息、常量、静态变量、即时编译器编译后的代码缓存、域信息、方法信息等。

如下图:

类型信息:

域信息:

方法信息


方法区中清除垃圾常量和垃圾类
1.常量:
常量不被引用,就会从常量池中清除
2.类:

需要满足以下条件:
1.该类的所有对象都已被清除
2.该类的java.lang.Class对象没有被任何对象或变量引用。只要一个类被虚拟机加载进方法区,那么在堆中就会有一个代表该类的对象:java.lang.Class。这个对象在类被加载进方法区的时候创建,在方法区中该类被删除时清除。
3.加载该类的ClassLoader已经被回收

关于回收的那些事

1.当触发GC时,对象被标记为垃圾就一定会被回收吗?
2.GCRoots就是固定的那些值吗?有没有可能会随着程序的运行而增加新的GCRoots?

1.当对象被标记为垃圾时,要清除其实还有一次标记过程,也就是说对象要被清除得经过两次标记过程:
当对象经过可达性分析后发现没有与GCRoots相关的引用链,他会被第一次标记,接着会进行判断是否要进行调用对象的finalize()方法,【判断条件:对象重写该方法并且该对象没有执行过该方法】。

第一种情况:
如果有必要执行finalize()方法的话,会将该对象添加到一个F-Quene的队列里面,稍后会有一个虚拟机自己建立并且调度优先级很低的Finalizer线程去执行对象的finalize()方法,由于该方法可能会陷入死循环或者执行缓慢导致队列中的其他对象永远不被调用finalizy方法,所以该方法并不一定保证能够执行完。接着稍后会对该队列中的对象进行第二次标记,如果还是没有对象引用它,那么将会被回收;如果有对象引用了它那么会进行将该对象移除队列中。

第二种情况:
如果没有必要执行【有两种情况:1.该对象没有重写该方法 2.该对象的finalizy方法已经执行过了】也就是说一个对象只有第一次GC被标记的时候可能逃逸不被回收,但是第二次GC标记的话就不行了。

提示:JAVA中并不提倡重写这个方法,最初是因为C和C++的人更容易接受JAVA。

2.GCRoots通常一般是以下对象:

1.在虚拟机栈的本地变量表中引用的对象
2.方法区中静态属性引用的对象
3.方法区中常量池中引用的对象
4.本地方法JNI中引用的对象
5.Class对象,常驻的异常对象(NullPointException,OOM等),系统类加载器
6.同步锁中持有的对象(比如Synchronized)
7.本地代码缓存

这些是固定的GCRoots集合,但是根据不同的垃圾收集器和当前回收区域内存的不同,还可以有其他对象“临时”加入GCRoots集合。
垃圾收集器中将堆划分为了不同的内存区域,这部分后面详解。当出现这么一种情况:老年代的对象引用年轻代的对象的时候,年轻代中的对象回收的时候不仅要遍历GCRoots是否有引用链之外,还需要遍历是否老年代有引用。这就意味着要把老年代全部遍历一遍才能确认是垃圾。

因此出现了记忆集这个概念:在新生代上建立一个数据结构(记忆集),这个结构里面将老年代的内存划分开,标识哪部分内存是跨域访问的对象(比如访问年轻代里面的对象)。当发生年轻代的GC时,会将这个数据结构里面的老年代对象标识为GC Roots进行扫描,而不用进行遍历整个老年代。【只有在记忆集中包含的小块内存里的对象才会被加入到GC Roots中】

以上是关于JVM初探(一):双亲委派机制的主要内容,如果未能解决你的问题,请参考以下文章

双亲委派机制JVM:类加载机制深度剖析 - 第7篇

打破双亲委派JVM:类加载机制深度剖析 - 第8篇

JVM之JVM初探

JVM快速初探

一:从JDK源码级别彻底剖析JVM类加载机制

JVM--双亲委派机制