JVM相关内容详解

        下面是一个JVM各个部分的图。我们的程序编译之后就会生成.class文件,然后文件经过我们的类加载子系统之后就完成了初始化,初始化完成之后就是进入运行时数据区,然后我们的执行引擎就会对文件进行解释,编译执行。在内存不足的时候还会出发GC。这个图并没有太详细,后面分开说明的时候我会画出来(堆和虚拟机栈的具体结构)。

1.类加载子系统

        在我们的类加载子系统中我们会经历Loading,Linking,Initialization这三个阶段。

1.1第一阶段loading

        我们知道我们的我们的一个程序运行的时候其实是会加载很多类的。像是我们java.long包这些等等,而这些程序启动需要加载的类就是我们的BootStrap ClassLoader来加载的,像我们的Application ClassLoader和自定义ClassLoader就是主要加载我们自己写的程序代码。

        在这里面我们常常会听说一个词叫“双亲委派机制”。就是说其实这几个类加载器其实是有等级之分的,从自定义ClassLoader(自定义类加载器) ==>Application ClassLoader(系统类加载器) ==>Extension ClassLoader(扩展类加载器) ==>BootStrap ClassLoader(引导类加载器)。这是一个从低到高的顺序。

        举个例子,就比如说我们写了一个Application类加载器,但是我们并不会直接加载它,而是去问他的父类Extension ClassLoader(这里的父类并不是继承关系,如果看源代码的话它们都是sun.misc.Launcher这个包下的内部类,当然自定义不算,自定义是我们自己写的一个类,程序并不自带。)。Extension ClassLoader会继续上传直到BootStrap ClassLoader。如果BootStrap ClassLoader无法加载这个类(也就是说BootStrap ClassLoader所管理的类中没有这个需要加载的类)。那么就会下传到Extension ClassLoader继续上面步骤,Extension ClassLoader无法加载的话就会到Application ClassLoader。这就是双亲委派机制,先让最顶级的ClassLoader加载,如果它不加载,那么就下一级的加载,以此类推。

        当然双亲委派机制也会被破坏。(下面这一段是抄袭别人的(悄咪咪的说))

        第一次被破坏

        其实发生在双亲委派模型出现之前–即JDK1.2发布之前。由于双亲委派模型是在JDK1.2之后才被引入的,而类加载器和抽象类java.lang.ClassLoader则是JDK1.0时候就已经存在,面对已经存在 的用户自定义类加载器的实现代码,Java设计者引入双亲委派模型时不得不做出一些妥协。为了向前兼容,JDK1.2之后的java.lang.ClassLoader添加了一个新的proceted方法findClass(),在此之前,用户去继承java.lang.ClassLoader的唯一目的就是重写loadClass()方法,因为虚拟在进行类加载的时候会调用加载器的私有方法loadClassInternal(),而这个方法的唯一逻辑就是去调用自己的loadClass()。 JDK1.2之后已不再提倡用户再去覆盖loadClass()方法,应当把自己的类加载逻辑写到findClass()方法中,在loadClass()方法的逻辑里,如果父类加载器加载失败,则会调用自己的findClass()方法来完成加载,这样就可以保证新写出来的类加载器是符合双亲委派模型的。

        第二次被破坏

         这个模型自身的缺陷所导致的,双亲委派模型很好地解决了各个类加载器的基础类统一问题(越基础的类由越上层的加载器进行加载),基础类之所以被称为“基础”,是因为它们总是作为被调用代码调用的API。但是,如果基础类又要调用用户的代码,那该怎么办呢。 这并非是不可能的事情,一个典型的例子便是JNDI服务,它的代码由启动类加载器去加载(在JDK1.3时放进rt.jar),但JNDI的目的就是对资源进行集中管理和查找,它需要调用独立厂商实现部部署在应用程序的classpath下的JNDI接口提供者(SPI, Service Provider Interface)的代码,但启动类加载器不可能“认识”之些代码,该怎么办? 为了解决这个困境,Java设计团队只好引入了一个不太优雅的设计:线程上下文件类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个;如果在应用程序的全局范围内都没有设置过,那么这个类加载器默认就是应用程序类加载器。 有了线程上下文类加载器,JNDI服务使用这个线程上下文类加载器去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型,但这也是无可奈何的事情。Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI,JDBC,JCE,JAXB和JBI等。

        JDBC为什么需要破坏双亲委派机制?

         原因是原生的JDBC中Driver驱动本身只是一个接口,并没有具体的实现,具体的实现是由不同数据库类型去实现的。例如,MySQL的mysql-connector-.jar中的Driver类具体实现的。 原生的JDBC中的类是放在rt.jar包的,是由启动类加载器进行类加载的,在JDBC中的Driver类中需要动态去加载不同数据库类型的Driver类,而mysql-connector-.jar中的Driver类是用户自己写的代码,那启动类加载器肯定是不能进行加载的,既然是自己编写的代码,那就需要由应用程序启动类去进行类加载。于是乎,这个时候就引入线程上下文件类加载器(Thread Context ClassLoader)。有了这个东西之后,程序就可以把原本需要由启动类加载器进行加载的类,由应用程序类加载器去进行加载了。

1.2第二阶段Linking

        其实第二阶段也是比较的简单,验证主要就是验证这个.class文件是否满足JVM虚拟机规范。像是开头必须要以CAFABABY才能被识别。

        准备阶段主要就是将我们属性赋默认值,这也可以很明显的说明,为什么我们int a;打印a会直接打印出来0;这就是a被赋默认值了。准备阶段主要就是干这个事情。

        解析阶段主要就是将常量池内的符号引用转换为直接引用。

1.3第三阶段Initialization

        这一阶段主要就是将我们的那些属性赋上初始值。如int a=1;那么这个阶段之后,我们的a就会被赋值为1。

2.Runtime Data Areas(运行时数据区)

        大致的说明一下,在我们呢的运行时数据区中,我们的堆和我们的方法区(元空间)是所有线程共享的,而本地方法栈,PC寄存器和本地方法区是每个线程私有的。而且我们的垃圾回收是不会发生在我们的虚拟机栈,PC寄存器,本地方法栈这三个地方的。只可能出现在我们的堆和方法区中。

2.1堆

        我们来看以下堆的具体细节(这里我们统一为JDK8,而且垃圾回收算法为ps+po(JDK8的默认垃圾回收算法)),在堆中,我们主要就是存放我们的实例对象。同时它也是GC重点照顾的对象。

        这里我们可以看到,我们的堆被分成了老年代和新生代,在新生代中呢我们又可以分为edan,s0和s1这三个区域。至于TLAB在我们讲到栈的时候再说,这里有一个印象就好了(它主要就是用于在多线程同时访问堆的情况下使用的)。我们的edan:s0:s1=8:1:1(这是默认的参数,当然可以修改,我会在后面将参数列出来。),但是呢,当我们调用工具查看的时候,往往得不到8:1:1这样的比例(我测量的数据是6:2:2)。主要就是它有一个自适应的优化策略,如果想要得到8:1:1;那么就需要显示的设置参数。当然,我们的老年代和我们的新生代的大小比例是:老年代:新生代=2:1(这个比例也是可以调节的)。

        在我们的程序中,几乎所有的对象都是在新生代被new出来的,但是也是有例外的,比如说我们的这个对象过于太大,我们的新生代根本装不下,那么就会直接被放到老年区中。

        那么,我们新生代中的数据何时进入老年代呢,我们有一个计数器(用来计算对象的年龄,在每一次GC的时候,如果这个对象没有被回收,那么他的年龄就会+1),如果他的年龄达到了阈值(就是我们设置进入老年代的年龄,默认是15),那么就会被存到老年代中。

        下面就是一些可能用到了参数

1、-XX:+PrintFlagsInitial: 查看所有的参数的默认初始值
2、-XX:+PrintFlagsFinal:查看所有的参数的最终值(可能会存在修改,不再是初始值)
​
3、-Xms:初始堆空间内存 (默认为物理内存的1/64)
4、-Xmx:最大堆空间内存(默认为物理内存的1/4)
5、-Xmn:设置新生代的大小。(初始值及最大值,不建议使用)
​
6、-XX:NewRatio=2:配置新生代与老年代在堆结构的占比(默认为老年代:新生代=2:1)
7、-XX:SurvivorRatio=8:设置新生代中Eden和S0/S1空间的比例(默认为8:1:1,但实际是6:2:2)

2.2PC寄存器(程序计数器)

        其实PC寄存器用一句话来说就是用来指向下一条指令的地址。我么知道,我们的程序在执行的时候是逐条逐条执行的,中间还有可能调用其它的方法,进行循环,异常处理等等。那么,我们的执行引擎是如何知道我们应该执行的是那一条语句呢,这就和PC寄存器相关的。PC及存器里面就是存储的是我们下一条需要执行的指令的地址,当我们的执行引擎执行完当前行的时候,就会去PC寄存器中取出下一个指令的地址,然后执行。

2.3虚拟机栈

        首先,其实我们可以来说明以下栈的特点。栈,是遵从先进后出的原则的。在我们的虚拟机栈中,我们的栈是由我们的数组结构来实现的。栈的最基本的单位是栈帧,那么接下来我们就来看看栈的基本结构吧。

2.3.1局部变量表

        局部变量表主要用于存储方法参数和定义在方法体内部的局部变量,这些类型包括基本类型,对象引用等等。它的大小实在编译阶段就已经定好了的,不会改变。当栈弹出的时候,这些变量也随之销毁了。

        在局部变量表中,我们最基本的存储单元是slot(变量槽),它的大小为32位。像是我们的的short,byte,char,boolean这些不足四个字节的类型会被转换成int类型来存储,像是long和double这种8字节的类型就会占用两个slot

2.3.2操作数栈

主要就是根据我们的字节码指令来进行出栈和入栈操作。

public void test(){
    int a=3;
    int b=2;
    int i=a+b;
}

        像是上面这个程序,我们首先就会将我们的3压入我们的操作数栈,然后再将2压入操作数栈。第三部计算i的时候,我们就取出b,再取出a,然后让a和b进行相加,最后将结果又压入操作数栈中。流程如下:

 

2.3.3方法返回地址

        在线程中我们有两种方法让我们的方法结束,第一就是直接执行完毕,自动结束。第二种就是出现异常,强制结束。但无论是哪一种,我们都需要知道应该返回的地址是哪,方法返回地址中就存储的是这个方法需要返回的地址。在方法返回地址中,我们记录了该方法应当返回的位置。也就是我们应该return到哪去。

2.3.4动态链接

        动态链接其实是和运行时常量池是相关的。因为我们知道我们的栈中不可能太大,而且如果我们吧常量存放到栈中(这里指的常量其实是像是一些int,long这些东西,并非单纯的数字,这一点要明白。),那么我们每多一个线程就要重新存放这些东西,这样就显得很冗余。所以我们将这些内容统一放在我们的方法区中的运行时常量池中。而我们的方法区又是在我们的本地内存中。足够大,放心的存储。

        而我们的动态链接存放的就是地址的引用,指向我们的运行时常量池。这样我们栈中的空间就小的几乎可以忽略不计。

2.3.5一些附加信息

        就。。。见名之意吧,就是一些附加的信息,像是对程序调试提供支持什么的。(我也没怎么看这个,不重要)

2.4本地方法栈

        本地方法栈里面其实就是一些指向本地方法接口的一些指向什么的。我们知道我们有一些功能是我们java无法使用的,比如说我们去开启一个线程(因为我们java并不能直接操作我们的系统)。那么这时候,我们就需要一个方法来调用我们一些本地的方法,这些方法一般都是由C来编写的。我们通过关键字native来调用我们的本地方法。而这些东西我们的就存放在我们的本地方法栈。去调用我们的本地方法接口。(应该说清楚了吧,我觉得。)

2.5方法区(元空间/元数据)

        方法区这个名字不用太纠结,方法区,元空间,元数据都行。metaspace这个单词翻译过来叫元空间,也有叫元数据的。我觉得不用太纠结,就知道他是将我们的数据存储在我们本地的就好了。

        在方法区中呢,我们主要存储的就是我们的类的信息了,还有我们的上面说到的运行时常量池(当然,我们这里需要将我们的字符串常量池拿来单独的提出来,放到我们的堆中(这是JDK7之后做的改变))。

        其实我觉得方法区说到这就差不多了,再说一点吧,就是我们的方法区就是配合着我们的栈来运行程序的,我觉得。举个例子。

public class test{
    public static void main(String[] args){
        int a=20;
        int b=30;
        int x=100;
        int y=x/a;
        System.out.println(y+b);    
    }
}

 

        结合以下我们的操作数栈,我们来想像以下它的流程。先放入一个20(bipush),然后再放入一个30,再放入一个100,然后取出100(iload_3),取出20(iload_1),相除(idiv),存入位置4,调用运行时常量池中的System.out(getstatic #2),取出位置4中的数字,再取出位置2中的数字,然后相加。最后打印输出(invokevirtual #3)。看见了吗,我们的程序运行的时候,就是从我们的方法区中取数据出来,然后逐条逐条去运行的。

3.执行引擎

3.1解释器(Interperter)和即时编译器(JIT)

        我们都知道我们现在大多数用的都是HotSpot虚拟机,其实我觉得我们只需要知道这两个东西为什么会共存就好了。大致说一下,就是我们的解释器其实就是拿到代码就直接执行了,没有编译的事,但是呢,它的速度却比较的慢。我们的JIT呢就是需要将我们的程序完全的编译好了之后才进行运行的。所以它会花大量的时间去编译,到那时编译好了之后速度就很快。

        在一些特定的场景下,比如说我们需要我们程序的启动速度很快,那么我们就可以先用解释器来执行一些必要的文件,等我们的的编译器慢慢的编译。这样我们也就有着更高的用户体验度了。不然的话,它程序一直在那加载,也很烦人对吧。

        还有就是对一些热点数据的处理,我们对于一些高频访问的数据,肯定是需要我们使用我们JIT来做编译的,为了保证它的高效嘛。

        其实为什么要保留解释器,主要还是我们JAVA所面向的业务所决定的。

        前面说到了热点数据,那么我们如何知道哪些是热点数据呢,这就要看访问量和访问的频率了。而这个访问量的阈值默认的是10000次(在我们的Windows JDK中,因为Windows默认的是Server模式,且无法修改。Client模式是1500次)。这个阈值也可以设置。

-XX:CompileThreshold  设置阈值

        热点数据肯定不止这一个标准,还有一个就是频率,也叫热度衰减。如果我们在规定的时间内无法调用那么多次(阈值)的数据,那么这个数据在这个时间之后就会减少一半(5000变2500)。如果我们关闭了这个热度衰减,那么我们的程序总有一天会全都会被编译成本地代码(就是全都被JIT了)。

-XX:-UseCounterDecay    关闭热度衰减
-XX:CounterHalfLifeTime  设置的热度衰减的时间

3.2GC垃圾回收

        对于垃圾回收我们可以从两个方面来看。

第一个是如何判断一个对象是垃圾?

        对于这个问题我们一般有两种方法,第一种是引用计数法,第二种是可达性分析算法。

3.2.1引用计数法

        我们每调用一下这个方法,我们就让这个方法的计数加一,我们每释放一次这个方法就让我们的计数减一,在垃圾回收的时候,如果计数器的值为0,那么他就是垃圾,会被回收。就比如说我这个程序中a方法会被调用三次,那么我每调用一次,就让计数器加一。在我们垃圾回收的时候,就会去看这个计数器是否为0,为0则回收。

        这个方法其实有一个很明显的劣势,就是如果有两个方法相互引用,而其他方法又不会去调用这两个方法,那么他们两个就是垃圾,但是我们又无法去回收他们(因为他们的计数器不为0)。这样就会造成空间的浪费。因为我们的JAVA程序的特点,我们并没有选择这种方法。

3.2.2可达性分析算法

        我们JAVA程序所使用的算法就是这个。我们从根节点出发,然后向下走(就是看他所使用的方法有哪些)。如果是没有走到的地方,那么就会在垃圾回收的时候被回收掉。感觉用文字不怎么说得清楚。看图:

 

        从跟开始,向下走,如果是被链接到的方法,自然不是垃圾,如果是没有被链接到的,那就是垃圾。

        根其实可以简单的理解为非堆的其他对象,比如说栈中有一个方法要调用堆,那么栈中的那个方法就是根。或者更加细节一点就是以谁为对象,那么除了这个对象以外的其他对象都可以称之为根。就比如说我要看新生代中有哪些垃圾,那么栈中,老年代中,方法区中的对象都可以看作是根。

        这一个算法也是有一定的问题的,那就是我们在找根的时候,会去枚举根节点,这样会浪费很多的时间。因为会进行STW(Stop The World)。

3.3.3标记清除算法

        区分于上面两种算法(因为上面是找垃圾的算法,下面是回收垃圾的算法)。我是偷图小能手(手动滑稽)

 

        从图中我们可以看到,这个算法就两个步骤,标记和清除,很简单吧。先将我们是垃圾的对象给标记出来,然后直接清除。这个算法的速度在这三种之中算是中等,也不会产生额外的空间,但是它会产生碎片化的空间,就像是回收了垃圾之后,那个小格子被空了出来,如果我有一个大的对象需要连续的空间来存储,很明显它没有足够的连续空间,存储不下。这样的碎片化空间如果无法存储对象的话,那么就造成了空间的浪费。

3.3.4复制算法

 

        复制算法呢,更是简单直接的暴力。开辟两个空间,然后谁是存活对象,就把谁给复制下来,可以很明显的看出,在这三种算法中,它是最快的一种垃圾回收算法。当然,缺点也很明显,就是浪费空间。在算法中,这就是一种典型的空间换时间的算法。

3.3.5标记压缩算法

 

        对比于标记清除算法,标记压缩算法就是在清除完垃圾之后,在将剩余存货对象进行一个空间整理。这种算法在三种算法中是最慢的,但是他不会产生额外的空间,也不会有空间的浪费。

        这三种算法其实各有各的有点,就看你实际的应用场景是什么。并没有什么优劣势之分。具体问题具体分析。

3.3.6垃圾收集器

        最后我们再来看看垃圾收集器。

 

        我们JDK8主要用的是ps+po也就是Parallel Scavenge GC+Parallel Old GC。

        首先我们先来看Serial GC+Serial Old GC。这个垃圾回收器主要作用于内存较小的地方,比如单片机什么的。它是一个单线程的垃圾回收器。现在一般都不怎么用了。

        然后是我们的ParNew GC。相比较于Serial GC,ParNew GC就是一个多线程版本。在年轻代中采用的是复制算法。

        Parallel回收器,和我们的ParNew一样,Parallel Scavenge也是一个多线程的垃圾回收器。它的有优点就是有一个可控的吞吐量。可以说,parallel的吞吐量是最大的。

        CMS GC是一个低延迟的垃圾处理器,同时,他也是一个并发的垃圾收集器。他可以实现程序线程和垃圾回收线程同时进行。

        G1垃圾收集器又是一个跨时代的垃圾收集器了。因为它去除了传统的分代垃圾收集器,改为分区(但是我觉得其实还是分代)。就是将我们的堆分成一个个的小空间,每一块空间给他一命一个区,像什么这一块是edan,这一块是s区,这一块是老年代等等。这样我们就可以低延迟,多次回收。每次只回收一部分,让我们的延迟降低。触发垃圾回收的阈值我记得应该是45%的空间被占满。

        其实垃圾收集器要细说的话还是能说很多很多的!!!但是我不想写了,大晚上的,我要玩会游戏去,哈哈哈!!!

        最后,其实这个博客写得并不是很详细,很多地方我应该都没有写的很细。这个主要就是记录以下我学习JVM之后,为了再巩固加深来写的。你可以看成这只是一个JVM的入门而已,很多东西我都只是大致的说了一下,如果想要学习更多,我觉得还是需要去买书来看。但那都是后面的事情了,至于垃圾回收器这一部分,后面有时间的话再来细说吧。最后,因为今天写的时候思路断断续续的,感觉应该是有些地方没有写到,像是字符串常量池这些,但是。。。不想写了,嘿嘿。就这样吧,散会!!!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值