JVM--虚拟机

最近重新学习java虚拟机,以下内容是自己对于jvm的理解以及学习笔记

1.java是如何运行的呢?

java源文件通过javac命令编译成字节码文件也就是.class文件,然后jvm通过类加载器将在字节码文件读取到内存中。Java虚拟机真正识别的是字节码文件的—字节码指令

2.java虚拟机是什么?

在这里插入图片描述
Jvm严格来说是一套规范,由很多厂商去实现,比如oracal,谷歌IBM。
Java语言有一个特点:一次编译处处运行,一套代码只需要编译一次,就可以放在windows,linux,mac多个操作系统下执行。计算机真正执行的(操作系统最底层的执行的代码)是二进制的机器码。即便是同一套程序,在不同的操作系统下jvm都会将其编译成不同的机器码。
怎么做到的呢?我们在安装jdk的时候会针对自己的操作系统来选择jdk版本,机器码不同是靠不同操作系统对应的jdk里面的jvm实现的。所有jvm是从软件层面屏蔽不同操作系统在底层硬件与指令上的区别

3.jvm具体包含哪些内容?

在这里插入图片描述
1.Jvm由运行时数据区类加载器执行引擎组成。
运行时数据区又由也叫虚拟机栈或者线程栈、方法区本地方法栈程序计数器组成
其中堆、方法区线程共享的,本地方法栈、程序计数器、栈是线程独享的。

4.运行时数据区的各个模块

这里先给出一个Math 类如下

package com;

class Math {
    public static final Integer CONSTANT = 666;

    public int compute() { //- 个方法对应一块栈帧内存区域
        int a = 1;
        int b = 2;
        int C = (a + b) * 10;
        return C;
    }

    public static void main(String[] args) {
        Math math = new Math();
        math.compute();
        Math math2 = new Math();
        math2.compute();
    }
}

运行时数据区,各个模块存的是什么样的数据呢?
在这里插入图片描述

4.1、堆
堆存放的是new出来的对象,当然当堆内存存满了后,对象没地方放了,会触发gc,也就是垃圾回收机制。gc的具体内容会在下面提到。
4.2、栈
栈中存的是基本数据类型和堆中对象的引用
栈实际是一种数据结构—先进后出(first in last out)
当程序执行到某个方法时就会为这个方法在栈中开辟一块空间(内存区域),这块内存区域叫做栈帧,一个方法对应一块栈帧内存区域。当方法执行完就会销毁释放方法对应的局部内存区域,也就是出栈。
栈帧内部还有更细的结构:
在这里插入图片描述
局部变量表:存放局部变量。比如compute方法中的变量a,b,c;当变量是对象类型时比如main方法中的math,math2,这里存的是对象的引用而不是对象本身。
Compute方法对应普通的局部变量,a,b,c;而main方法对应对象类型的局部变量,比如math,math2,对象类型局部变量有一个指针指向堆中的Math对象。
Math1与math2是两个不同的对象,但都是由同一个模板类new出来的,所以执行的是相同的指令码
对象的组成部分包含了一个对象头:指向对象所属的类的类结构信息
临时操作数栈:存放在代码执行过程中,一些临时的操作数也就是赋值变量的值的临时中转存储区域。
我们应该知道了jvm真正执行的是jvm指令码,而不是直接运行int a = 1之类的代码
指令码是怎样子的呢?Math 类的compute方法的指令码如下:
在这里插入图片描述
指令码的具体意思:
Iconst_1: 定义局部变量1为一个int类型的变量。也就是a
istore_ 1:将int类型值存入局部变量1 .也就是将2 存入a,到这里也就完成了int a = 2
Iconst_2: 定义局部变量2为一个int类型的变量。也就是b
istore_ 2: 将int类型值存入局部变量2, 也就是将3 存入b,到这里也就完成了int b = 3.
iload 1: 从局部变量1中装载int类型值,就是把a的值2拿出来。(这里拿出的2会放到操作数栈中)
iload 2: 从局部变量2中装载int类型值,就是把b的值3拿出来。(这里拿出的3会放到操作数栈中)
iadd:执行int类型的加法,也就是3+2得到5
binpush 10:将10放入临时操作数栈,
imul:进行int类型的乘法,也就是10乘以5得到50
(到这里操作数栈中只有50这一个值)
istore_3:将值(50)存入局部变量3中; 到这里也就完成了 int C = (a + b) * 10;
到此应也知道临时操作数栈是干嘛的了。

方法出口:当方法执行完后应该返回到代码对应的那一行。
动态链接:执行的方法对应的指令码的内存地址,是程序运行中动态生成的。

4.3、程序计数器
程序计数器存放线程马上要执行的或者正在执行的jvm指令码的行号或者内存地址。也就是用来记录正在执行的代码的位置。每执行一行jvm指令码,执行引擎会对程序计数器的值进行一次更新。

4.4、方法区
方法区存常量,静态变量,类结构信息(字节码文件里有哪些方法,哪些些常量也就是类的组成结构)jdk1.8之后叫元空间,使用的内存是直接内存也就是物理内存虚拟机外的内存,而不是虚拟机的内存,jdk1.8之前永久栈。

4.5本地方法栈
存本地方法(native method)的数据,本地方法底层由c语言实现

4.6 方法区、堆、栈的联系
栈中变量是对象类型时,栈中对象引用指向堆中的实际对象,堆中的对象通过对象头的指针指向方法区对应的类结构信息。

5.垃圾收集(gc)

垃圾收集主要是对堆中的无用对象进行清除,当然还有方法区;
注意:垃圾回收回收的是无任何引用的对象占据的内存空间而不是对象本身。
堆的大体内部结构如下:
在这里插入图片描述
堆空间主要分为年轻代和老年代,年轻代又分为eden区和survivor(survivor分为From区也叫survivor1区和To区也叫survivor2区);其中老年代占三分之二空间, Eden区占年轻代的十分之八;
当eden区满了,执行引擎会进行minor gc垃圾收集,对无效的对象进行清除,并将还存活的对象存入from区,同时存活的对象的分代年龄(分代年龄是对象头里的一部分)就会变成1。当 from区域满了后会再进行minor gc,清除无用对象,仍存活的对象会被放入To区,对象的分代年龄加一,当To区满了再进行minor gc,并将还存活的对象存入from区,from区满了就进行minor gc,并将还存活的对象to区,这样来来回回,每进行一次minor gc 分代年龄就会加一,当对象的分代年龄达到15还存活时,会将对象放入老年代,当老年代满了时会进行full gc;当老年代满了,而老年代的对象都是存活的那么再向老年代放对象时会造成内存溢出。
这里的minor gc 和fullgc 下面还会详细提到

5.1、那怎么判断对象是否存活,或者说怎样的对象是无用的?

通过两种方法判断:
1.引用计数算法
此处参考:java垃圾回收算法之-引用计数器
简单理解就是给每个对象分配一个计算器,当有引用指向这个对象时,计数器加1,当指向该对象的引用失效时,计数器减一。最后如果该对象的计算器为0时,java垃圾回收器会认为该对象是可回收的

优点:
无需等到内存不够的时候,才开始回收,运行时根据对象的计数器是否为0,就可以直接回收。并且更新对象的计数器时,只是影响到该对象,不会扫描全部对象
缺点:
1、每次对象被引用时,都需要去更新计数器,有一点时间开销。另外无法解决循环引用问题。例如:虽然a和b都为null,但是由于a和b存在循环引用,这样a和b永远都不会被回收。
2、浪费cpu,即使内存够用,仍然在运行时进行计数器的统计
现在很少会使用引用计数算法了,而是使用可达性分析

2.可达性分析
 这个算法的基本思想就是通过一系列的称为“GC Roots"的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连的话,则证明此对象是不可用的。
在这里插入图片描述
GC Roots限节点包括:类加载器、Thread、 虚拟机栈的本地变量表、static成员、 常量引用、本地方法栈的变量等等
到这里可能对GCRoots根还不太理解,那举个例子:
在前面有提到一个Math类,他的虚拟机的结构图如下:
(这里main方法里,我们只看 Math math = new Math();math.compute();部分)
在这里插入图片描述
compute方法在栈空间有自己独立的栈帧,栈帧里存了compute方法里的math变量的引用,当compute被执行完以后,会销毁释放compute方法对应的局部内存区域,这个时候这个引用就没有了。那么math对象就没有引用指向他了,也就成为了无用对象,而math对象就是一个GCRoots根。

5.2 常用的垃圾收集算法

5.3.1 标记-清除算法
标记-清除算法采用从根集合(GC Roots)进行扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收.
标记-清除算法不需要进行对象的移动,只需对不存活的对象进行处理,在存活对象比较多的情况下极为高效,但由于标记-清除算法直接回收不存活的对象,因此会造成内存碎片。

5.3.2 复制算法
复制算法说到底也是为了解决标记-清除算法产生的那些碎片。赋值算法是用空间换时间
首先将内存分为大小相等的两部分(假设A、B两部分),每次呢只使用其中的一部分(这里我们假设为A区),等这部分用完了,这时候就将这里面还能活下来的对象复制到另一部分内存(这里设为B区)中,然后把A区中的剩下部分全部清理掉。

5.3.3 标记-整理算法
标记-整理算法采用标记-清除算法一样的方式进行对象的标记,但在清除时不同,在回收不存活的对象占用的空间后,会将所有的存活对象往左端空闲空间移动,并更新对应的指针。标记-整理算法是在标记-清除算法的基础上,又进行了对象的移动,因此成本更高,但是却解决了内存碎片的问题。

5.3.4 分代收集算法
(1) 年轻代(Young Generation或者minor gc)的回收算法

a) 所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。

b) 新生代内存按照8:1:1的比例分为一个eden区和两个survivor(survivor0,survivor1)区。一个Eden区,两个 Survivor区(一般而言)。大部分对象在Eden区中生成。回收时先将eden区存活对象复制到一个survivor0区,然后清空eden区,当这个survivor0区也存放满了时,则将eden区和survivor0区存活对象复制到另一个survivor1区,然后清空eden和这个survivor0区,此时survivor0区是空的,然后将survivor0区survivor1区交换,即保持survivor1区为空, 如此往复。

c) 当survivor1区不足以存放 eden和survivor0的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次Full GC,也就是新生代、老年代都进行回收。

d) 新生代发生的GC也叫做Minor GC,MinorGC发生频率比较高(不一定等Eden区满了才触发)。

(2)年老代(Old Generation)的回收算法
a) 在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。
b) 内存比新生代也大很多(大概比例是1:2),当老年代内存满时触发Major GC即Full GC,Full GC发生频率比较低,老年代对象存活时间比较长,存活率标记高。

(3)持久代(Permanent Generation)的回收算法
  用于存放静态文件,如Java类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate 等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。持久代也称方法区。
  方法区主要回收的内容有:废弃常量和无用的类。对于废弃常量也可通过引用的可达性来判断,但是对于无用的类则需要同时满足下面3个条件:
1.该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例;
2.加载该类的ClassLoader已经被回收;
3.该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

6 类加载机制

6.1 什么是类加载器

虚拟机中的一部分,负责读取 Java 字节代码,并转换成java.lang.Class类的一个实例;

6.2 类加载器种类

启动类加载器(Bootstrap ClassLoader)
这个类加载器负责将\lib目录下的类库加载到虚拟机内存中,用来加载java的核心库,此类加载器并不继承于java.lang.ClassLoader,不能被java程序直接调用,代码是使用C++编写的.是虚拟机自身的一部分.

扩展类加载器(Extendsion ClassLoader)
这个类加载器负责加载\lib\ext目录下的类库,用来加载java的扩展库,开发者可以直接使用这个类加载器.

应用程序类加载器(Application ClassLoader)
这个类加载器负责加载用户类路径(CLASSPATH)下的类库,一般我们编写的java类都是由这个类加载器加载,这个类加载器是CLassLoader中的getSystemClassLoader()方法的返回值,所以也称为系统类加载器.一般情况下这就是系统默认的类加载器.

自定义类加载器
通过继承java.lang.ClassLoader类实现,一般是加载我们的自定义类 。
要创建用户自己的类加载器,只需要继承java.lang.ClassLoader类,然后覆盖它的findClass(String name)方法即可,即指明如何获取类的字节码流。

6.3 双亲委派模型

6.3.1 什么是双亲委派模型
类加载器 Java 类如同其它的 Java 类一样,也是要由类加载器来加载的;除了启动类加载器,每个类都有其父类加载器。
双亲委派模型就是当某个特定的类加载在接收到类加载的请求的时候,首先需要将加载任务委托给父类加载器,依次递归到顶层后,如果最高层父类能够找到需要加载的类,则成功返回,若父类无法找到相关的类,则依次传递给子类,由子类尝试自己加载。
顺序Bootstrap ClassLoader→Extendsion ClassLoader→Application ClassLoader。

6.3.2 双亲委派的好处
1:避免同一个类被多次加载;
2:每个加载器只能加载自己范围内的类;
6.3.3 如何打破双亲委派机制:
1:自己写一个类加载器
2:重写loadclass方法
3:重写findclass方法
这里最主要的是重写loadclass方法,因为双亲委派机制的实现都是通过这个方法实现的,先找附加在其进行加载,如果父加载器无法加载再由自己来进行加载,源码里会直接找到根加载器,重写了这个方法以后就能自己定义加载的方式了。

发布了45 篇原创文章 · 获赞 6 · 访问量 1165

猜你喜欢

转载自blog.csdn.net/qq_41219586/article/details/103941001