JVM的运行原理

转载自https://blog.csdn.net/csdnliuxin123524/article/details/81303711

 


 

JVM的运行原理

目录

JVM的运行原理

类加载器

1、分类

2、加载机制

3、一个类加载器的理想化组成

4、类加载器特性

5、类加载器的最终成果

方法区

1、方法区的内容是每个装载进来的类信息,根据内外部环境划分

2、类型的常量池。

3、方法区在虚拟机启动的时候创建。

4、方法区同样存在垃圾收集

5、方法区的大小不必是因定的

虚拟机栈

本地方法栈

执行引擎

JAVA的内存模型

问题总结


1、必要性
​
2、JVM是源文件(.java)运行在操作系统(语言,windows操作系统语言是c++)的媒介,从main()开始
​
3、运行,编译(由jdk/eclipse完成),把.java转成.class文件 
​
4、类加载器(classLoader是类加载器,一个是ClassLoader实体类),什么是类加载器,怎么加载的,分类,继承关系,双亲委派模型,最终产物,new()A 的过程
​
5、运行时数据区划分
​
6、方法区,作用,组成,(进程与线程的区别)
​
7、虚拟机栈,特性,组成,入栈出栈过程,栈帧组成,动态链接特性
​
8、堆,特性,GC回收过程
​
9、程序计数器,特性
​
10、本地方法栈,特性
​
11、执行引擎,功能,运行方式(解释器,即时编译器)
​
12、整合实例
​
13、计算机硬件与JVM间的线程关系,线程安全发送原因,注意事项。



类加载器

1、分类

2、加载机制

双亲委派类加载机制

1、启动程序,首先做初始化(init)是加载jdk自带的jar包,这部分由bootstarp类加载器完成。然后程序找到main(),创建A对象。创建A类的过程,A.class文件进入到类加载器,首先由默认的类加载器去查看此类加载器有没有已经加载过A类(保证一个类文件只加载一次),这个默认的类加载器就是Application ClassLoader。如果Application 没有加载过A类,那就到它的上级类加载器查看有没有加载过,也就是ExtClassLoader.如果没有就到bootStrap类加载查看有没有加载过。如果还没有 ,就到bootStrap负责加载的jar包中找没有此A类,如果找不到,就到下级ExtClassLoader查找有没有此A类.如果没有就到AppClassLoader查找有没有A类。如果还没有就到user类加载器中找A类。如果还没有就报classNotFoundException。
(总结,两个过程,一是自下而上的找没有加载过,二是自上而下的去加载。)

3、一个类加载器的理想化组成

4、类加载器特性

1、不止一个种类,各种类完成各自的分内工作,也就是各加载各自的jar包。
2、不止一个类加载器,好多个
3、可以自定义类加载器

5、类加载器的最终成果

把加载的类放到方法区里面,并在堆里面生成相应的实体。

方法区

1、方法区的内容是每个装载进来的类信息,根据内外部环境划分

外部信息:
1、类型全名(包名+类名)
2、类型的父类型的全名
3、给类型是一个类还是接口
4、类型的修饰符
5、所有父接口全名的列表
​
内部信息:
1、类型的常量池
2、类型字段信息
3、类型的方法信息
4、所有的静态类变量(非常量)信息
5、一个指向类加载的引用
6、一个指向class类的引用
​

从上面的分类可以看出方法区内存放的类信息进行了分类统一处理

2、类型的常量池。

在工作学习中我们会听到过很多常量池,字符串常量池,运行时常量池,这里又来个类型的常量池。其实这些常量池的作用和使用原理都是一样的, 不用太过区分。
​
其使用目的:
1、为了避免频繁的创建和销毁对象而影响系统性能,其实现了对象的共享。
​
2、类型的常量池(也叫class 常虽池)用于存放编译器生成的各种字面量(uteral)和符号引用(Symbolic References);

什么是字面量和符号引用?

字面量包括: 
1.文本字符串
2.八种基本类型杂值
3.被声明为final的常量等;
​
符号引用包括: 
1.类和方法的全限定名
2.字段的名称和描述符
3.方法的名称和描述符。
常量池就是这个类型用到的常量的一个有序集合, 池中的数据像数组项- 样,是通过索引访问的。 因为常量池存储了一个类型所使用到的所有类型,域和方法的符号引用,所以它在java程序 的动态链接中起了核心的作用。其实存储和访问方式同string常量池。每个class文件都有一个class常量池。

3、方法区在虚拟机启动的时候创建。

所有jvm线程共享,也就是不随启动的线程数的变化而变化,而且必须考虑数据的线程安全。假如两个线程都在试图找lava的类,在lava类还没有被加载的情况下,只应该有一个线程去加载, 而另一个线程等待。

4、方法区同样存在垃圾收集

因为通过用户定义的类加载器可以动态扩展java程序,一 些类也会成为垃圾。jvm可以回收一个未被引用类所占的空间,以使方法区的空间最小。

5、方法区的大小不必是因定的

jvm可 以根据应用的需要动态调整。如果超出方法区最大内存,会报出java.lang.OutofMemoryError.

(1)是Java虚拟机所管理的内存中最大的一块。
(2)堆是jvm所有线程共享的。
(3)在虚拟机启动的时候创建。
(4)唯一目的就是存放对象实例,几乎所有的对象实例以及数组都要在这里分配内存。
(5) Java堆 是垃圾收集器管理的主要区域。
(6) java堆是计算机物理存储.上不连续的、逻辑上是连续的,也是大小可调节的(通过Xms和Xmx控制)。
(7)如果在堆中没有内存完成实例的分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。
(8)因此很多时候java堆也被称为“GC堆”(Garbage Collected Heap)。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆还可以细分为:新生代和老年代;新生代又可以分为: Eden 空间、From Survivor空间、To Survivor空间。见下图:

① 新生区

新生区是类的诞生、成长、消亡的区域,一个类在这里产生,应用,最后被垃圾回收器收集,结束生命。
​
新生区又分为两部分:
伊甸区 (Eden space)和幸存者区(Survivor pace),所有的类都是在伊甸区被new出来的。
幸存区有两个:
    0区(Survivor 0 space)和1区(Survivor 1 space)。
    当伊甸园的空间用完时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园进行垃圾回收(Minor GC),将伊甸园中的剩余对象移动到幸存0区。若幸存0区也满了,再对该区进行垃圾回收,然后移动到1区。那如果1去也满了呢?再移动到养老区。若养老区也满了,那么这个时候 将产生Major GC(FullGCC),进行养老区的内存清理。若养老区执行Full GC 之后发现依然无法进行对象的保存,就会产生OOM异常 “OutOfMemoryError”。

如果出现java.lang.OutOfMemoryError: Java heap space异常,说明Java虚拟机的堆内存不够。原因有二:

 a.Java虚拟机的堆内存设置不够,可以通过参数-Xms、-Xmx来调整。
 b.代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)。

② 养老区

养老区用于保存从新生区筛选出来的 JAVA 对象,一般池对象(需要经常创建的对象,不用创建和销毁,省去时间,并不产生内存碎片)都在这个区域活跃。

③ 永久区

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

如果出现java.lang.OutOfMemoryError: PermGen space,说明是Java虚拟机对永久代Perm内存设置不够。原因有二:

 a. 程序启动需要加载大量的第三方jar包。例如:在一个Tomcat下部署了太多的应用。
 b. 大量动态反射生成的类不断被加载,最终导致Perm区被占满。

说明:

Jdk1.6及之前:常量池分配在永久代 。
Jdk1.7:有,但已经逐步“去永久代” 。
Jdk1.8及之后:无(java.lang.OutOfMemoryError: PermGen space,这种错误将不会出现在JDK1.8中)。
(9)GC具体什么时候执行,这个是由系统来进行决定的,是无法预测的。

虚拟机栈

作用:主要用于方法的执行。

​
(1)线程私有的,它的生命周期与线程相同,每个线程都有一个。 
(2)每个线程创建的同时会创建一个JVM栈,JVM栈中每个栈帧存放的为当前线程中局部基本类型的变量(java中定义的八种基本类型: boolean、char、byte、short、int、long、float、double;和对象引用(reference 32 位以内的数据类型,具体根据JVM位数(64为还是32位)有 关,因为一个solt(槽)占用32位的内存空间 ,64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的基本类型只占用1 个)、部分的返回结果,非基本类型的对象在JVM栈上仅存放一个指向堆上的地址; 
(3)每一个方法从被调用直至执行完成的过程就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。 
(4)虚拟机栈空间可以动态扩展,当动态扩展是无法申请到足够的空间时,抛出OutOfMemory异常。 
(5)虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作 栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程 
(5)栈运行原理:栈中的数据都是以栈帧(Stack Frame)的格式存在,栈帧是一个内存区块,是一个数据集,是一个有关方法和运行期数据 的数据集,当一个方法A被调用时就产生了一个栈帧F1,并被压入到栈中,A方法又调用了B方法,于是产生栈帧F2也被压入栈,B方法又调用了 C方法,于是产生栈帧F3也被压入栈…… 依次执行完毕后,先弹出后进......F3栈帧,再弹出F2栈帧,再弹出F1栈帧。见下图

动态链接根据方法在方法区中存放的位置去找到对应的方法,再将其压入栈中
上图表示的就是一个线程的虚拟机栈的组成结构,由很多方法栈帧组成,每个栈帧又分为局部变量,操作数帧等几部分组成。执行过程就是先执行main方法,main方法被压入栈底,main方法又调用method1()方法,method1由调用method2,以次类推,当最上面的方法执 行完了就弹出栈。这也就是我们常说的栈是先进后出的方式,也就是桶状模型。 
(6)对上图中的动态链接解释下,比如当出现main方法需要调用method1()方法的时候,操作指令就会触动这个动态链接就会到方法区中找 method1(),然后把method1()方法压入虚拟机栈中,执行method1栈帧的指令;此外如果指令表示的代码是个常量,这也是个动态链接,也会到 方法区中的运行时常量池找到类加载时就专门存放变量的运行时常量池的数据

本地方法栈

(1)先解释什么是本地方法:jvm中的本地方法是指方法的修饰符是带有native的但是方法体不是用java代码写的一类方法,这类方法存在的意义当然是填补java代码不方便实现的缺陷而提出的。 
(2)作用同java虚拟机栈类似,区别是:虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则是为虚拟机使用到的Native方法服务。 
(3)是线程私有的,它的生命周期与线程相同,每个线程都有一个。
(4)会报出OutOfMemoryError异常。 
(5)优势:不用经过执行引擎解释器,可以直接与操作系统交互,在一定程度上加快运行速度。再者是有的任务在java层次不易实现,需要直 接与操作系统交互。 
(6)缺点:本身java是很灵活的,一次编译,到处运行。但是如果在不同语言编写的操作系统,因此使用了native的程序可移植性不太高。 
(7)图解与虚拟机栈的交互,如下图

native方法是没有方法实体的,形式如下:
    public class IHaveNatives
    {
      native public void Native1( int x ) ;
    } 
它的方法体是通过动态链接到内存或硬盘中获取的,然后直接在操作系统上运行。
以windows平台为例,方法体是用c语言编写的,一般以.dll文件格式。
​
本地方法也可以调用java方法。 
因为其实现体是由非java代码在在外部实现的,不能与abstract连用,他们都是方法的声明,如果同时出现,就相当于即把实现移交给子类,又把实现移交给本地操作系统,那到底谁来实现具体方法呢? 
(8)为什么native方法修饰的修饰的方法PC程序计数器为undefined。读懂上面的所有知识点可以就很容易自己理解了。在一开始类加载时,native修饰的方法就被保存在了本地方法栈中,当需要调用native方法时,调用的是一个指向本地方法栈中某方法的地址,然后执行方法直接与 操作系统交互,返回运行结果。整个过程并没有经过执行引擎的解释器把字节码解释成操作系统语言,PC计数器也就没有起作用。 
(9)本地方法一般很少用到,不用做太多了解。

程序计数器(Program Counter Register)

也叫PC寄存器,是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令、分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
(1)计算机硬件中也有个pc寄存器,跟这个功能大致一样,只是所处的位置不同。 
(2)当虚拟机正在执行的方法是一个本地(native)方法的时候,jvm的pc寄存器存储的值是undefined。 
(3)程序计数器是线程私有的,它的生命周期与线程相同,每个线程都有一个。 
(4)此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

执行引擎

(1)功能:

方法区以及栈中的指令还是人能够看懂的,这里执行引擎的工作就是要把指令转成JVM执行的语言(也可以理解成操作系统的语言),最后操作系统语言再转成计算机机器码。

(2)有两种运行方式

1、解释器:
一条一条地读取,解释并且执行字节码指令。因为它一条一条地解释和执行指令,所以它可以很快地解释字节码,但是执行起来会比较慢。这是解释执行的语言的一个缺点。字节码这种“语言”基本来说是解释执行的。
​
2、即时(Just-In-Time)编译器:
即时编译器被引入用来弥补解释器的缺点。执行引擎首先按照解释执行的方式来执行,然后在合适的时候,即时编译器把整段字节码编译成本地代码。然后,执行引擎就没有必要再去解释执行方法了,它可以直接通过本地代码去执行它。执行本地代码比一条一条进行解释执行的速度快很多。 编译后的代码可以执行的很快,因为本地代码是保存在缓存里的。热点代码,执行次数比较多的代码

(3)程序在JVM主要执行的过程是执行引擎与运行时数据区不断交互的过程,执行引擎拿到的pc寄存器的指令(这里是单个指令,一条一条地执行)地址,到栈 帧中获取指令,计算结果,返回到栈。下图是个经典的交互过程。

首先位于.class文件中,被类加载器加载到方法区,然后main方法启动,运行到foo方法,把foo方法压入到栈帧中也就是上图左侧是foo方法指令集;然后,常数1入栈,栈顶元素就是1,然后栈顶元素移入局部变量区存储,常数2入栈,栈顶元素变为2,然后 栈顶元素移入局部变量区存储;接着1,2依次再次入栈,弹出栈顶两个元素相加后结果入栈,将5入栈,栈顶两个元素弹出并相乘后结果入栈,然后栈顶变为15,最后移入局部变量。执行return命令如果当前线程对应的栈中没有了栈帧,这个Java栈也将会 被JVM撤销。
​
就是在方法区,程序计数器就不用说了,局部变量区位于虚拟机栈中,右侧最下方的求值栈(也就是操作数栈)我们从动图中 明显可以看出存在栈顶这个关键词因此也是位于java虚拟机栈的。
​
另外,图中,指令是Java代码经过javac编译后得到的JVM指令,PC寄存器指向下一条该执行的指令地址,局部变量区存储函数运 行中产生的局部变量,栈存储计算的中间结果和最后结果。

JAVA的内存模型

这里主要思考的是线程和线程安全问题

在运行时数据内存区中虚拟机栈、pc寄存器、本地方法栈是每个线程都有的,很明显这些都是独立的不会发生线程不安全的问题,但是我们平时讨论的线程不安全、要加锁等等情况是怎么回事呢?

答:其实,发生线程不安全问题的原因在于cpu,看下图,简单理解Cpu。见下图

  

在CPU内部有一组CPU寄存器,也就是CPU的储存器。CPU操作寄存器的速度要比操作计算机主存快的多。在主存和CPU寄存器之间还存在一个CPU缓存,CPU操作CPU缓存的速度快于主存但慢于CPU寄存器。某些CPU可能有多个缓存层(一级缓存和二级缓存)。计算机的主存也称作RAM,所有的CPU都能够访问主存,而且主存比上面提到的缓存和寄存器大很多。 
​
当一个CPU需要访问主存时,会先读取一部分主存数据到CPU缓存,进而在读取CPU缓存到寄存器。当CPU需要写数据到主存时,同样会先flush寄存器到CPU缓存,然后再在某些节点把缓存数据flush到主存。

Java内存模型和硬件架构之间的桥接

正如上面讲到的,Java内存模型和硬件内存架构并不一致。硬件内存架构中并没有区分栈和堆,从硬件上看,不管是栈还是堆,大部分数据都 会存到主存中,当然一部分栈和堆的数据也有可能会存到CPU寄存器中,如下图所示,Java内存模型和计算机硬件内存架构是一个交叉关系:

当对象和变量存储到计算机的各个内存区域时,必然会面临一些问题,其中最主要的两个问题是:

1、共享对象对各个线程的可见性
2、 共享对象的竞争现象 

问题1:共享对象的可见性

当多个线程同时操作同一个共享对象时,如果没有合理的使用volatile和synchronization关键字,一个线程对共享对象的更新有可能导致其它线 程不可见。
想象一下我们的共享对象存储在主存,一个CPU中的线程读取主存数据到CPU缓存,然后对共享对象做了更改,但CPU缓存中的更改后的对象还 没有flush到主存,此时线程对共享对象的更改对其它CPU中的线程是不可见的。最终就是每个线程最终都会拷贝共享对象,而且拷贝的对象位 于不同的CPU缓存中。
​
下图展示了上面描述的过程。左边CPU中运行的线程从主存中拷贝共享对象obj到它的CPU缓存,把对象obj的count变量改为2。但这个变更对运 行在右边CPU中的线程不可见,因为这个更改还没有flush到主存中

要解决共享对象可见性这个问题,我们可以使用java volatile关键字。 Java’s volatile keyword. volatile 关键字可以保证变量会直接从主存读取,而对变量的更新也会直接写到主存。volatile原理是基于CPU内存屏障指令实现的。

问题2:竞争现象

如果多个线程共享一个对象,如果它们同时修改这个共享对象,这就产生了竞争现象。 
如下图所示,线程A和线程B共享一个对象obj。假设线程A从主存读取Obj.count变量到自己的CPU缓存,同时,线程B也读取了Obj.count变量到它 的CPU缓存,并且这两个线程都对Obj.count做了加1操作。此时,Obj.count加1操作被执行了两次,不过都在不同的CPU缓存中。
​
如果这两个加1操作是串行执行的,那么Obj.count变量便会在原始值上加2,最终主存中的Obj.count的值会是3。然而下图中两个加1操作是并行 的,不管是线程A还是线程B先flush计算结果到主存,最终主存中的bj.count只会增加1次变成2,尽管一共有两次加1操作。

要解决上面的问题我们可以使用java synchronized代码块。synchronized代码块可以保证同一个时刻只能有一个线程进入代码竞争区, synchronized代码块也能保证代码块中所有变量都将会从主存中读,当线程退出代码块时,对所有变量的更新将会flush到主存,不管这些变量是 不是volatile类型的。

volatile和 synchronized区别

volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized则是锁定当前变量,只有当前线 程可以访问该变量,其他线程被阻塞住。
​
volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的 
volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性
volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。 
volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化

cpu内部线程处理方式:

一个核心处理器可以处理两个线程。最重要的是,一个核心的一个线程对jvm的多线程的操作不是一对一,cpu的一个 线程不是只处理一个jvm线程,每隔一段时间(毫秒级甚至微秒级)就会换一个jvm线程去执行,当前线程等待。优点,就是如果一个线程中存 在sleep,就省去了一段等待时间。当然,jvm可以设置线程执行优先级的。

问题总结

1,怎么查看一个类的.class文件呢?

答:写好你的.Java文件后,file---open file,打开所在工程的.bin目录(不同工程有所不同),就是放工程的.class文件,然后打开就行了。 也可以直接ctrl+shift+R搜索文件名,不过右上角要设置成show derived Resources,就能显示,class文件了。最后要注意的是你的eclipse不 能安装了反编译插件哦,不然打开后跟.java文件内容一样了。

2,方法区存放的是类文件

(静态文件,比如变量值是不随程序的运行而改变的,一个类只有一个。)在栈帧中运行方法时,需要new A对象,就会在堆中为A开辟空间,创建A对象。栈中是指向堆中A对象的地址。当又new A对象,堆中会再开辟一个空间,创建一个A对象。但是方法区始终只有一个A类文件。这是一对多的关系。

3,jdk,jre,JVM的关系:

JDK(Java Development Kit)是Java 语言的软件开发工具包(SDK)。在JDK的安装目录下有一个jre目录,里面有两 个文件夹bin和lib,在这里可以认为bin里的就是jvm,lib中则是jvm工作所需要的类库,而jvm和Lib(初始化的jar包)合起来就称为jre。

4,GC垃圾回收机制

不是创建的变量为空是就被立刻回收,而是超出变量的作用域后就被自动回收。

5,java虚拟机的生命周期:

声明周期起点是当一个java应用main函数启动时虚拟机也同时被启动,而只有当在虚拟机实例中的所有非守护 进程都结束时,java虚拟机实例才结束生命。 

6,java虚拟机与main方法的关系:

main函数就是一个java应用的入口,main函数被执行时,java虚拟机就启动了。启动了几个main函数就 启动了几个java应用,同时也启动了几个java的虚拟机。

7,java的虚拟机种有两种线程

一种叫叫守护线程(服务员),一种叫非守护线程(也叫普通线程,比作厨师),main函数就是个非守护线程,虚拟机的gc就是一个守护线程。java的虚拟机中,只要有任何非守护线程还没有结束,java虚拟机的实例都不会退出,所以即使main函数这个非守护线程退出,但是由于在main函数中启动的匿名线程也是非守护线程,它还没有结束,所以jvm没办法退出

8,堆内存大小-Xms -Xmx设置相同,可以避免每次垃圾回收完成后JVM重新分配内存。

9,我们平时所说的八大基本类型的在栈中的存放位置是:

运行时数据区-->虚拟机栈-->虚拟机栈的一个栈帧-->栈帧中的局部变量表; 
​
局部变量表存放的数据除了八大基本类型外,还可以存放一个局部变量表的容量的最小单位变量槽(slot)的大小,通常表示为reference; 所以是可以放字符串类型的,但是要以 String a="aa";的形式出现,如果是new Object()那就只能实在堆中了,栈里面存的是栈执行堆的地 址。在执行引擎的图中,只涉及到int a=1;int b=2;并没有String或对象类型的使用,所以也就没有关联到堆。

10,字符串常量池:

在JDK6.0及之前版本,字符串常量池是放在Perm Gen区(也就是方法区)中; 
在JDK7.0版本,字符串常量池被移到了堆中了。至于为什么移到堆内,大概是由于方法区的内存空间太小了。 
JDK8以后也还是放在了Heap空间中,并没有已到元空间。
常量池的特点是:字符串常量池中的字符串只存在一份!
String s1 = "hello,world!";
String s2 = "hello,world!";
即执行完第一行代码后,常量池中已存在 “hello,world!”,那么 s2不会在常量池中申请新的空间,而是直接把已存在的字符串内存地址返 回给s2。既然在堆地址和值都一样,那么返回给栈去判断s1==s2的结果自然也是相等的了。
发布了82 篇原创文章 · 获赞 52 · 访问量 4万+

猜你喜欢

转载自blog.csdn.net/iostream992/article/details/104452770
今日推荐