JVM

JVM

1. JVM介绍

Jvm是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。虚拟机是物理机的软件实现。Java是用WORA(编写一次运行到任何地方)的概念开发的,它在VM上运行。编译器将Java文件编译成Java .class文件,然后将.class文件输入JVM, JVM加载并执行类文件。引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。

1.1 JVM的位置

在这里插入图片描述

JVM是运行在操作系统之上的,与硬件没有直接的交互,但是可以调用底层的硬件,用JIN (Java本地接口调用底层硬件接口,了解下就好,已经过时了)

2. JVM体系结构概览

img

栈、本地方法栈、程序计数器不会发生gc。

jvm调优主要在堆,方法区有一小部分。

3. 类装载器ClassLoader

3.1 类装载器的概念

作用:加载.class文件。

新建的对象放入堆里面,引用(地址)放到栈,其中引用指向堆里面对应的对象

负责加载class文件,class文件在文件开头有特定的文件标识 ,将class文件字节码内容加载到内存中,并将这些内容转换成方法区中的运行时数据结构并且ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定。

img

解释

     Car.class  是由 .java 文件 经过编译而得来的 .class文件,存在本地磁盘  
  ClassLoader: 类转载器,作用就是加载并初始化 .class文件 ,得到真正的 Class 类,即模板
  Car Class : 由 Car.class 字节码文件,通过ClassLoader 加载并初始化而得,那么此时 这个 Car 就是当前类的模板,这个Car Class 模板就存在 【方法区】
  car1,car2,car3 : 是由Car模板经过实例化而得,即 new出来的 --> Car car1 = new Car() , Car car2 = new Car() ,Car car3 = new Car() , 因此可知,由一个模板,可以得到多个实例对象,即模板一个,实例多个  
  所以,拿car1举例,car1.getClass 可以得到其模板Car 类,Car.getClassLoader() 可得到其装载器  

3.2 类装载器的种类

    虚拟机自带的加载器  
        ① 启动类加载器 也叫根加载器 (Bootstrap) ,由C++编写 ,程序中自带的类, 存储在$JAVAHOME/jre/lib/rt.jar中,如object类等
        ② 扩展类加载器 (Extension) ,Java 编写 ,在我们平时看到的类路径中,凡是以javax 开头的,都是拓展包,存储在$JAVAHOME/jre/lib/ext/*.jar 中
        ③ 应用程序类加载器 (AppClassLoader),即平时程序中自定义的类 new出来的  
        Java也叫系统类加载器,加载当前应用的classpath的所有的类
         
      用户自定义加载器  
        Java.lang.ClassLoader的子类,用户可以定制类的加载方式,即如果你的程序有特殊的需求,你也可以自定义你的类加载器的加载方式 ,进入ClassLoader的源码,其为抽象类,因此在你定制化开发的时候,需要你定义自己的加载器类来继承ClassLoader抽象类即可,即 MyClassLoader extends ClassLoader

img

 1)虚拟机自带的加载器
 2)启动类(根)加载器 Bootstrap ClassLoader
 3)扩展类加载器 Extension ClassLoader
 4)应用程序(系统类)加载器 Application ClassLoader
     所以,Java 的类的加载机制,永远是从 启动类加载器 -->  拓展类加载器  --> 应用程序类加载器  这样的一个顺序进行加载

3.3 类装载器的双亲委派机制

       先举一个栗子,来说明下啥叫双亲委派,比如 有一个类叫 A.java  ,当要使用A类时,类加载器要先去 启动类加载器(Bootstrap)中去找,如果找到就使用启动类加载器中的A类,不继续往下执行,但是如果找不到,则依次下放,去 拓展类加载器 中找,同理找到就用,找不到就继续下放,再去 应用程序类加载器中找,找到就用,此时找不到就会报classNotFund Exception的异常。 
 
    概念:
        当一个类收到类加载请求,它首先不会尝试自己去加载这个类,而是先把这个请求委派给父类去完成,每一个层次类加载器都是如此,因此所有的类加载请求都是应该传到启动类加载器中的,只有当其父类加载器自己无法完成这个请求的时候(在他的加载路径下没有找到所需加载的Class),子类加载器才会尝试自己去加载。
 
    采用双亲委派的一个好处就是比如加载位于rt.jar包中的类java.lang.Object,不管是哪个加载器加载这个类,最终都是会委托给顶层的启动类加载器进行加载,这样就保证了使用不同的类加载器最终得到的都是同一个Object对象。
 检查顺序从下至上,加载顺序从上到下。如果一个类加载器需要加载类,那么首先它会把这个类请求委派给父类加载器去完成,每一层都是如此。一直递归到顶层,当父加载器无法完成这个请求时,子类才会尝试去加载。

3.4 类装载器沙箱安全机制

        通过双亲委派机制,类的加载永远都是从 启动类加载器开始,依次下放,保证你所写的代码,不会污染Java自带的源代码,所以出现了双亲委派机制,保证了沙箱安全
        控制远程代码执行的权限。
    jdk1.6之后的域。

Execution Engine 执行引擎负责解释命令,提交操作系统执行。

4. Native Interface 本地接口

     native :在Java中是一个关键字,有声明,无实现。  
    以线程为例,不要以为线程是属于Java的一个东西,其实它是属于操作系统底层的,Java中通过Thread类的start() 类启动一个线程,

img

 进入Thread的start()的源码,你会看到虽然调用的是start(),但其实调用的start0()这个方法, 最终是由 private native void start0(); 这段代码去跟底层做了交互实现,有声明,无实现,Java到此交由系统去处理了。  

img

    这里引申个题外知识哈,即我new 一个线程,当我执行 thread.start() ;这个方法之后,是不是会立即执行这个线程呢?  
          答案就是不一定,因为当你创建一个线程,调用start()方法后,是将线程从初始化状态变为就绪状态,而真正的执行,是要等cpu来进行调度,你才能执行,否则你就跟那就绪着,千万别信誓旦旦的说,肯定会立即执行,还是建议看一下 操作系统的课本。

    本地接口的作用是融合不同的编程语言为Java所用,它的初衷是想融合C/C++ 程序,Java诞生之初,正式C/C++ 盛行之时,于是在内存中专门开辟了一块区域处理标记为native的代码,它的具体做法就是 Native Method Stack 中登记 native 方法,在 Exection Engine 执行时加载 native libraies。 
native关键字:

包含native关键字的方法,说明java的作用范围拿不到了,会去调用底层c语言的库。
会进入本地方法栈。
调用本地方法接口JNI。
JNI作用:扩展java的使用,融合不同的编程语言为java所用。 最初:c、c++
它在内存区域中专门开辟了一块标记区域:native method stack,登记native方法
在最终执行的时候,通过JNI加载本地方法库中的方法

5. Native Method Stack 本地方法栈

    它的具体做法是 Native Method Stack 中登记 native 方法,在 Execution Engine 执行时加载本地方法库。

6. 程序计数器 (PC寄存器)

    记录了方法之间的调用和执行情况,类似班级的值日表,用来存储指向下一条指令的地址,也即将要执行的指令代码

     每个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法区中的方法字节码(用来存储指向下一条指令的地址,也即将要执行的指令代码。建议看一下【计算机操作系统】这本书,其实不光有pc,还有时间片的轮转,这里不多做介绍),有执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不计。

    这块存储区域很小,他是当前线程所执行的字节码的行号指示器,字节码解释器通过改变这个计数器的值来选取下一条需要执行的字节码指令。

    如果执行的是一个Native方法,那这个计数器就是空的,因为native已经不属于Java的范畴了 。

    用以完成分支、循环、跳转、异常处理、线程恢复等基础功能。不会发生内存溢出(OutOfMemory = OOM)错误。  

7. 方法区 Method Area

     供各线程 共享 的运行时内存区域。----存储了每一个类的结构信息---- 

            例如:运行时常量池(Runtime Constant Pool)、字段 和 方法数据、构造函数和普通方法的字节码内容,存储内容:static、final、.class、常量池。

     方法区就是一个规范,在不同的虚拟机里实现是不一样的,最典型的就是 永久代(PermGen space) 和 元空间 (Metaspace)  
            永久代:JDK1.7
            元空间:JDK1.8
            但是两者都不存在方法区中,就像上面所讲,方法区存的是模板,而永久代和元空间是这套模板的实例
        所以,实例变量存储在堆内存中,和方法区无关。
方法区是被所有线程共享的,所有的字段和方法字节码,以及一些特殊方法,如构造函数,接口代码也在此定义,简单说,所有定义方法的信息都保存在该区域,此区域属于共享空间

存储内容:static、final、.class、常量池

8. 栈

    记住 : 栈管运行,堆管存储
           栈也叫栈内存,主管Java程序的运行,是在线程创建时创建,他的生命周期是跟随线程的生命周期,线程结束那么栈内存也就随之释放, 对于栈来说不存在垃圾回收问题 ,只要线程已结束该栈就over了,是线程私有的。8种基本类型的变量 + 对象的引用变量 + 实例方法都是在函数的栈内存中分配。

     栈存储什么?
            栈帧中主要保存 3 类数据 : (何为栈帧:即Java中的方法,只是在jvm中叫做栈帧)
            - ① 本地变量 (Local Variables) : 入参和出参 以及方法内的变量;
            - ② 栈操作 (Operand Stack) : 记录出栈 和 入栈的操作;(可理解为pc寄存器的指针)
            - ③ 栈帧数据 (Frame Data) : 包括类文件、方法等。

    栈的运行原理:
            栈中的数据都是以栈帧 (Stack Frame) 的格式存在,栈帧是一个内存区块,是一个有关方法和运行期数据的数据集,

            当一个方法A被调用时就产生了栈帧 F1,并被压入到栈中,
            A方法又调用 B方法,于是产生栈帧F2 ,也被压入栈,
            B方法又调用 C方法, 于是产生栈帧F3,也被压入栈 
            ……
            执行完毕后,先弹出F3栈帧,再弹出 F2栈帧,再弹出 F1栈帧 …… 

        遵循 “先进后出” / “后进先出” 原则。

            每个方法执行的同时都会创建一个栈帧,用于存储局部变量表,操作数据栈,动态连接、方法出口等信息,每一个方法从调用直至执行完毕的过程,就对应着一个栈帧在虚拟机中入栈到出栈的操作过程  
            栈的大小和具体jvm的实现有关,通常在 256K ~ 756K 之间,约等于 1Mb左右。

img

          StackOverflowError  即SOF
            比如:Exception in thread "main" java.lang.StackOverflowError  
 
            原因: 是因为方法的加载深度的调用后,将栈撑爆了, 即栈溢出
 
            那么 StackOverflowError 属于 异常 还是属于错误 ?答案是属于 Error级别的错误 ,看下图

img

对象实例化

 public class Customer{
     int id = 1002;
     String name;
     Account acct;
     
    {
         name = "匿名";
    }
     public Customer(){
         acct = new Account();
    }
 }
 class Account{
 }

image-20200516110826437

总的来说就是,JDK1.7之前,运行时常量池(字符串常量池也在里边)是存放在方法区,此时方法区的实现是永久带。
JDK1.7字符串常量池被单独从方法区移到堆中,运行时常量池剩下的还在永久带(方法区)
JDK1.8,永久带更名为元空间(方法区的新的实现),但字符串常量池池还在堆中,运行时常量池在元空间(方法区)。

9. 堆

Heap,一个JVM只有一个堆内存,堆内存的大小是可以调节的。

堆内存划分:

1)新生区 :Eden、S0、S1。对象在这里诞生、成长、甚至死亡

Eden:所有对象都是在eden区new出来的。

2)老年区

3)永久区:jdk1.8以后,叫元空间(方法区在这里,常量池在方法区里)。这个区域是常驻内存的,用来存放jdk自身携带的class对象。

Java堆内存又溢出了!教你一招必杀技

9.1 堆的划分

1、堆结构分代的意义

  Java虚拟机根据对象存活的周期不同,把堆内存划分为几块,一般分为新生代老年代永久代(对HotSpot虚拟机而言),这就是JVM的内存分代策略。   堆内存是虚拟机管理的内存中最大的一块,也是垃圾回收最频繁的一块区域,我们程序所有的对象实例都存放在堆内存中。给堆内存分代是为了提高对象内存分配和垃圾回收的效率。试想一下,如果堆内存没有区域划分,所有的新创建的对象和生命周期很长的对象放在一起,随着程序的执行,堆内存需要频繁进行垃圾收集,而每次回收都要遍历所有的对象,遍历这些对象所花费的时间代价是巨大的,会严重影响我们的GC效率。   有了内存分代,情况就不同了,新创建的对象会在新生代中分配内存,经过多次回收仍然存活下来的对象存放在老年代中,静态属性、类信息等存放在永久代中,新生代中的对象存活时间短,只需要在新生代区域中频繁进行GC,老年代中对象生命周期长,内存回收的频率相对较低,不需要频繁进行回收,永久代中回收效果太差,一般不进行垃圾回收,还可以根据不同年代的特点采用合适的垃圾收集算法。分代收集大大提升了收集效率,这些都是内存分代带来的好处。

2、堆结构分代

img1)堆的GC操作采用分代收集算法。

2)堆区分了新生代和老年代;

3)新生代又分为:Eden空间、From Survivor(S0)空间、To Survivor(S1)空间。

Java虚拟机规范规定:Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,也就是说堆的内存是一块块拼凑起来的。要增加堆空间时,往上“拼凑”(可扩展性)即可,但当堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

 Java虚拟机将堆内存划分为新生代、老年代和永久代,永久代是HotSpot虚拟机特有的概念(JDK1.8之后为metaspace替代永久代),它采用永久代的方式来实现方法区,其他的虚拟机实现没有这一概念,而且HotSpot也有取消永久代的趋势,在JDK 1.7中HotSpot已经开始了“去永久化”,把原本放在永久代的字符串常量池移出。永久代主要存放常量、类信息、静态变量等数据,与垃圾回收关系不大,新生代和老年代是垃圾回收的主要区域。

3、新生代(Young Generation)

  新生成的对象优先存放在新生代中,新生代对象朝生夕死,存活率很低,在新生代中,常规应用进行一次垃圾收集一般可以回收70% ~ 95% 的空间,回收效率很高。   HotSpot将新生代划分为三块,一块较大的Eden(伊甸)空间和两块较小的Survivor(幸存者)空间,默认比例为8:1:1。划分的目的是因为HotSpot采用复制算法来回收新生代,设置这个比例是为了充分利用内存空间,减少浪费。新生成的对象在Eden区分配(大对象除外,大对象直接进入老年代),当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。   GC开始时,对象只会存在于Eden区和From Survivor区,To Survivor区是空的(作为保留区域)。GC进行时,Eden区中所有存活的对象都会被复制到To Survivor区,而在From Survivor区中,仍存活的对象会根据它们的年龄值决定去向,年龄值达到年龄阀值(默认为15,新生代中的对象每熬过一轮垃圾回收,年龄值就加1,GC分代年龄存储在对象的header中)的对象会被移到老年代中,没有达到阀值的对象会被复制到To Survivor区。接着清空Eden区和From Survivor区,新生代中存活的对象都在To Survivor区。接着, From Survivor区和To Survivor区会交换它们的角色,也就是新的To Survivor区就是上次GC清空的From Survivor区,新的From Survivor区就是上次GC的To Survivor区,总之,不管怎样都会保证To Survivor区在一轮GC后是空的。GC时当To Survivor区没有足够的空间存放上一次新生代收集下来的存活对象时,需要依赖老年代进行分配担保,将这些对象存放在老年代中。

4、老年代(Old Generationn)

  在新生代中经历了多次(具体看虚拟机配置的阀值)GC后仍然存活下来的对象会进入老年代中。老年代中的对象生命周期较长,存活率比较高,在老年代中进行GC的频率相对而言较低,而且回收的速度也比较慢。

5、永久代(Permanent Generationn)

  永久代存储类信息、常量、静态变量、即时编译器编译后的代码等数据,对这一区域而言,Java虚拟机规范指出可以不进行垃圾收集,一般而言不会进行垃圾回收。

9.2. 永久代和方法区

1、方法区

方法区与堆有很多共性:线程共享、内存不连续、可扩展、可垃圾回收,同样当无法再扩展时会抛出OutOfMemoryError异常。

正因为如此相像,Java虚拟机规范把方法区描述为堆的一个逻辑部分,但目前实际上是与Java堆分开的(Non-Heap)。

方法区个性化的是,它存储的是已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

方法区的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是回收确实是有必要的。

2、永久代

  永久带又叫Perm区,只存在于hotspot jvm中,并且只存在于jdk7和之前的版本中,jdk8中已经彻底移除了永久带,jdk8中引入了一个新的内存区域叫元空间metaspace。并不是所有的jvm中都有永久带,ibm的j9,oracle的JRocket都没有永久带,永久带是实现层面的东西,永久带里面存的东西基本上就是方法区规定的那些东西。永久区(元空间)使用的是直接内存,与新生代和老年代分开。

3、区别

  方法区是规范层面的东西,规定了这一个区域要存放哪些东西,永久带或者是metaspace是对方法区的不同实现,是实现层面的东西。

4、hotspot jdk8中移除了永久带以后的内存结构

10. 堆、栈、方法区 三者的关系

img

img

      HotSpot是使用指针的方式来访问对象 :
      Java堆中存放访问类元数据的地址(即模板的地址)
      reference存储的是直接对象的地址

11. JVM之四种GC算法详解


1. GC简介


先看图: 在这里插入图片描述

GC是分代收集算法(更为专业)

  1. JVM在进行GC时,并非每次都对上面三个内存区域一起回收的,大部分时候回收的都是指新生代。

  2. 因此GC按照回收的区域又分两种类型,一种是普通GC(minor GC),一种是全局GC(major GC or Full GC)

Minor GC 和 Full GC的区别

  1. 普通GC(minor GC):只针对在新生代区域的GC,指发生在新生代的垃圾收集动作,因为大多数Java对象存活率都不高,所以Minor GC非常频繁,一般回收速度也比较快。

  2. 全局GC(major GC or Full GC):指发生在老年代的垃圾收集动作,出现了Major GC,经常会伴随至少一次的Minor GC(但不是绝对的)。Major GC的速度一般要比Minor GC慢上10倍以上。


2. GC算法之引用计数法

在这里插入图片描述 即当对象的引用为0的时候会被回收。


3. GC算法之复制算法(Copying)


(1)概述:

年轻代使用的是Minor GC,这种GC算法采用的是复制算法。


(2)原理

  1. Minor GC会把Eden中的所有活的对象都移到Survivor区域中,如果Survivor区放不下,那么剩下的活的对象就被移到Old generation中,也即一旦收集后,Eden是变成空的了。

  2. 当对象在Eden(包括一个Survivor区域,这里假设是from区域)出生后,在经过一次Minor GC后,如果对象还存活,并且能够被另一块Survivor区域所容纳(上面假设是from区域,这里应为to区域,即to区域有足够的内存空间来存储Eden和from区域存活的对象),则使用复制算法将这些仍然还存活的对象复制到另外一款Survivor区域(即to区域)中,然后清理所使用的Eden和Survivor(即from区域),并且这些对象的年龄设置为1,以后对象在Survivor区域每熬过一次Minor GC,就将对象的年龄+1,当对象的年龄达到某个值时(默认是15岁,通过 -XX:MaxTenuringThreshold来设定对象在新生代中存活的次数),这些对象就会成为老年代。

  3. 复制算法的基本思想是将内存分为两块,每次只用到其中一块,当这一块内存用完,就将活着的对象复制到另外一块上面。复制算法不会产生空间碎片


(3)复制算法的缺点:

  1. 浪费了一半的内存

  2. 如果对象的存活率很高,我们可以极端一点,假设100%存活,那么我们需要将所有对象都复制一遍,并将所有引用地址重置一遍,复制这一工作所花费的时间,在对象存活率达到一定程度时,将会变得不可忽视。所以从以上的描述中不难看出,复制算法要想使用,最起码对象的存活率要非常低才行,而且最重要的是,我们必须克服50%内存的浪费

在这里插入图片描述 在这里插入图片描述

在这里插入图片描述


4. GC算法之标记清除(Mark-Sweep)


(1)概述:

  1. 老年代一般是由标记清除或者是标记清除与标记整理的混合实现


(2)原理

当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为stop the world),然后进行两项工作,第一项则是标记,第二项则是清除。

  1. 标记:从引用根节点开始标记所有的被引用的对象。标记的过程其实就是遍历所有的GC Roots,然后将所有GC Roots可达的对象标记为存活的对象

  2. 清除:遍历整个堆,把未标记的对象清除。


(3)标记清除算法的缺点

  1. 此算法需要暂停整个应用,会产生内存碎片。

用通俗的话解释一下标记清除算法,就是当程序运行期间,若可以使用的内存被耗尽的时候,GC线程就会被触发并将程序暂停,随后将依旧存活的对象标记一遍,最终再将堆中所以没被标记的对象全部清除掉,接下来便让程序恢复运行。


(4)简单图:

在这里插入图片描述 在这里插入图片描述 在这里插入图片描述 在这里插入图片描述


5. GC算法之标记压缩(Mark-Compact)


(1)概述:

  1. 老年代一般是由标记清除或者是标记清除与标记整理的混合实现。


(2)原理

在这里插入图片描述


(3)标记压缩算法的缺点:

1.标记整理算法的唯一的缺点就是效率也不高,不仅要标记所有存活对象,还要整理所有存活对象的引用地址。从效率上来说,标记整理算法要低于复制算法。


6. GC算法之标记清除压缩(Mark-Sweep-Compact)

  1. 标记清除压缩,不属于四种GC算法。

在这里插入图片描述


流程图: 在这里插入图片描述 在这里插入图片描述 在这里插入图片描述


7. 小结

内存效率:复制算法>标记清除算法>标记整理算法(此处的效率只是简单的对比时间复杂度,实际情况不一定如此)。

内存整齐度:复制算法=标记整理算法>标记清除算法。

内存利用率:标记整理算法=标记清除算法>复制算法。

可以看出,效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存,而为了尽量兼顾上面所提到的三个指标,标记/整理算法相对来说更平滑一些,但效率上依然不尽如人意,它比复制算法多了一个标记的阶段,又比标记/清除多了一个整理内存的过程

年轻代(Young Gen)

 年轻代特点是区域相对老年代较小,对像存活率低。
 这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对像大小有关,因而很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解。

老年代(Tenure Gen)

 老年代的特点是区域较大,对像存活率高。
 这种情况,存在大量存活率高的对像,复制算法明显变得不合适。一般是由标记清除或者是标记清除与标记整理的混合使用

 

posted @ 2021-02-27 15:15  秋分的秋刀鱼  阅读(196)  评论(0)    收藏  举报