学习笔记-JAVAJVM-JVM的基本结构及概念

申明:文章内容是本人学习极客时间课程所写,文字和图片基本来源于课程资料,在某些地方会插入一点自己的理解,未用于商业用途,侵删。

原资料地址:课程资料

什么是JVM

原文连接:
原文连接

JVM是Java Virtual Machine(Java虚拟机)的缩写,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。由一套字节码指令集、一组寄存器、一个栈、一个垃圾回收堆和一个存储方法域等组成。JVM屏蔽了与操作系统平台相关的信息,使得Java程序只需要生成在Java虚拟机上运行的目标代码(字节码),就可在多种平台上不加修改的运行,这也是Java能够"一次编译,到处运行的"原因。

JVM的主要板块

类加载子系统

类加载器的定义(深入理解JVM原话):

通过一个类的全限定名称来描述此类的二进制字节流,将这个动作放到Java虚拟机外部去实现,以便让应用程序自己据欸的那个如何区获取所需要的类,实现这个动作的代码模快称为类加载器。

1 JVM 的类加载是通过ClassLoader及子类来完成的,通常来说有下面几种类加载器:

  • 启动类加载器(Bootstrap ClassLoader)
    负责加载JAVA_ HOME\lib目录的或通过-Xbootclasspath参数指定路径中的且被虚拟机认可(rt.jar) 的类库
  • 扩展类加载器(Extension ClassLoader)
    负责加载JAVA_ _HOME\lib\ext目录或通过java.ext.dirs系统变量指定路径中的类库
  • 应用程序类加载器(Application ClassLoader)
    负责加载用户路径classpath上的类库
  • 自定义类加载器
    加载应用之外的类文件

2 类加载执行顺序

检查顺序是自底向上:加载过程中会先检查类是否被已加载,从Custom到BootStrap逐层检查,只要某个类加载器已加载就视为此类已加载,保证此类所有ClassLoader只加载一 次.

3 加载时机(检查时自底向上,加载时自顶向下)

1-遇到new、getStatic、 putStatic、 invokeStatic四条指令时。

2-使用java.lang.reflect包方法时,对类进行反射调用。

3-初始化-个类时,发现其父类还没初始化,要先初始化其父类。

4-当虚拟机启动时,用户需要指定--个主类Main,需要先将主类加载。

4 一个类的一生

5 类加载所做的事情

在类加载的过程中,做了如下几件事情:

1 根据全限定名称加载二进制字节流。

2 将字节流转换为数据结构

3 创建字节码class的对象

6 类加载途径

➢01-jar/war

➢02-jsp生成的class

➢03-数据库中的二进制字节流

➢04-网络中的二进制字节流

➢05-动态代理生成的二进制字节流

自定义类加载器案例heiloworld

java 复制代码
public class CustomClassLoader extends ClassLoader {
    private final String classPath;
    public CustomClassLoader(String classPath) {
        this.classPath = classPath;
    }
    public static void main(String[] args) {
        CustomClassLoader customClassLoader = new CustomClassLoader("E:\\lesson-one\\lesson-one\\src\\lib");
        try {
            Class<?> c = customClassLoader.loadClass("com.learn.lessonone.dto.Test");
            if (c != null) {
                Object o = c.newInstance();
                Method say = c.getMethod("say", null);
                say.invoke(o, null);
                System.out.println(c.getClassLoader().toString());
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    @Override
    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        try {
            byte[] calsssDate = getData(name);
            if (calsssDate != null) {
                return defineClass(name, calsssDate, 0, calsssDate.length);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        return super.loadClass(name, resolve);
    }
    @Override
    protected Object getClassLoadingLock(String className) {
        return super.getClassLoadingLock(className);
    }
    public byte[] getData(String className) {
        String path = classPath + File.separator + className.replace(".", File.separator) + ".class";
        try (InputStream in = new FileInputStream(path);
             ByteArrayOutputStream out = new ByteArrayOutputStream()
        ) {
            byte[] buffer = new byte[1024];
            int len = 0;
            while ((len = in.read(buffer)) != -1) {
                out.write(buffer, 0, len);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
}

类加载机制双亲委派

1-什么是双亲委派?

当一个类加载器收到类加载任务,会先交给其父类加载器去完成,因此最终加载任务都会传递到顶层的启动类加载器,只有当父类加载器无法完成加载任务时,才会尝试执行加载任务。

2-为什么需要双亲委派呢?

双亲委派其实是一种规范,它一定程度上能够保证安全性。就比如我们尝试用的Object,String类,如果我们没有委托父类进行加载,每个子类进行加载,如果这个时候我们自己写了一个类的全限定名称和系统的一模一样,这个时候它加载的就是我们写的类,这样就会导致我们使用的不是Java给我门头提供的Object类,从而程序完全乱套。

为什么双亲委派能够解决这个问题呢,因为我们会一直委托父类去加载,加载Object这种类最终都是由BootstrapClassLoader来加载,它保证了加载的一定是Java提供给我们的Object类,因为BootstrapClassLoader就是负责加载这类内置类的。

3-为什么还需要破坏双亲委派?

在实际应用中,双亲委派解决了Java 基础类统一加载的问题,但是却存在着缺陷。JDK中的基础类作为典型的API被用户调用,但是也存在API调用用户代码的情况,JNDI,SPI,这种情况就需要打破双亲委派模式。

例如:数据库驱动DriverManager。以Driver接口为例,Driver接口定义在]DK中,其实现由各个数据库的服务商来提供由系统类加载器加载。这个时候就需要启动类加载器来委托子类来加载Driver实现,这就破坏了双亲委派。

4-如何破坏双亲委派?

方式一:重写ClassLoader的loadClass方法

方式二:SPl,类委托自类加载器加载Class,以数据库驱动DriverManager为例

自己的理解:

自定义的类始最终都是由ApplicationClassLoader或自定义类加载器加载比如我写了一个CustomObject 继承了Object这个类 并定义了这个类字段结构。在执行加载的时候,CustomObject一直向上委托,最后发现BootStrapClassLoaer加载不了,然后又自顶向下回溯ApplicationClassLoader来加载CustomObject这个类,但是Object BootStrapClassLoaer是能加载的,在回到这里之前Oject已将被加载过了。
类加载的源码 可以类的加载时委托自己的父亲进行加载

java 复制代码
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 {
                   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) {
                   // 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;
       }
   }

运行时数据区概述

按照线程使用情况和职责分成两大类:

1)线程独享(程序执行区)

虚拟机栈,本地方法栈,程序计数器

不需要垃圾回收

2)线程共享(数据存储区域)

堆和方法区

存储类的静态数据和对象数据

需要垃圾回收

1)堆

是虚拟机所管理的内存中最大的一块,在虚拟器启动时创建,被所有线程共享。此区域的唯一目的就是存放对象实例,一般来说对象实例都是在这里分配内存,包括现对象、数组与运行时常量。

2)堆内存划分依据:

1.弱分代假说:大多数对象存活时间短。

2.强分代假说:熬过越多次的垃圾回收,就越难以被回收。

3.跨代引用假说:跨代引用的对象占少数。

划分情况如下图,1.6:

新生代和老年代,Eden空间,From Survivor, To Survivor。

·3)划分变迁

Young 年轻区 :主要保存年轻对象,分为三部分,Eden区、两个Survivor区。

Tenured 年老区 :主要保存年长对象,当对象在Young复制转移一定的次数后,对象就会被转移到Tenured区。

Perm 永久区 :主要保存class、method、filed对象,这部份的空间一般不会溢出,除非一次性加载了很多的类,不过在涉及到热部署的应用服务器的时候,有时候会遇到OOM :

PermGen space 的错误。

Virtual区: 最大内存和初始内存的差值,就是Virtual区。

由2部分组成,新生代(Eden + 2*Survivor ) + 年老代(OldGen )

JDK1.8中变化最大是,的Perm永久区用Metaspace进行了替换

注意:Metaspace所占用的内存空间不是在虚拟机内部,而是在本地内存空间中。区别于JDK1.7

取消新生代、老年代的物理划分

将堆划分为若干个区域(Region),这些区域中包含了有逻辑上的新生代、老年代区域

下面我们来验证区分划分:

1 我们需要先将不同版本的jdk下载到本地

2 接着借助jdk自带的工具来看JVM的相关信息

打开这个exe,然后按安装插件
插件下载地址

然后1.6版本我们可以看到:

同时我们使用命令

bash 复制代码
#可以查看堆内存结构,需要Java\bin目录下或者配置环境变量
jmap -heap 10420

4)虚拟机栈

栈帧(Stack Frame)是用于支持虚拟机进行方法执行的数据结构。

栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用至执行

完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。

栈内存为线程私有的空间,每个线程都会创建私有的栈内存,生命周期与线程相同,每个Java方法在执

行的时候都会创建一个栈帧(Stack Frame)。栈内存大小决定了方法调用的深度,栈内存过小则会导

致方法调用的深度较小,如递归调用的次数较少。

虚拟机栈可能出现的两种异常:

如果线程请求的栈深度大于虚拟机所允许的深度(Xss默认1m),会抛出StackOverflowError异常.

下面这个例子中,我们大约递归到2完多次就会堆栈溢出。

java 复制代码
public class DemoError {
    private static int cnt = 0;
    private  void call() {
        cnt ++;
        try {
            call();
        } catch (Throwable e) {
            e.printStackTrace();
            System.out.println(cnt);
        }
    }
    public static void main(String[] args) {
        DemoError demoError = new DemoError();
        demoError.call();
    }
}
bash 复制代码
#空间进一步缩小,递归的深度会进一步缩小
java -Xss256k DemoError 

如果在创建新的线程时,没有足够的内存去创建对应的虚拟机栈,会抛出OutOfMemoryError异常。

5)本地方法栈

本地方法栈和虚拟机栈相似,区别就是虚拟机栈为虚拟机执行Java服务(字节码服务),而本地方法栈为虚拟机使用到的Native方法(比如C++方法)服务。

6)方法区

方法区(Method Area)是可供各个线程共享的运行时内存区域,方法区本质上是Java语言编译后代码存储区域,它存储每一个类的结构信息,例如:运行时常量池、成员变量、方法数据、构造方法和普通方法的字节码指令等内容。很多语言都有类似区域。

方法区的具体实现有两种:永久代(PermGen)、元空间(Metaspace)

方法区存储下面这几种数据类型:

第一:Class

  1. 类型信息,比如Class(com.hero.User类)
  2. 方法信息,比如Method(方法名称、方法参数列表、方法返回值信息)
  3. 字段信息,比如Field(字段类型,字段名称需要特殊设置才能保存的住)
  4. 类变量(静态变量):JDK1.7之后,转移到堆中存储
  5. 方法表(方法调用的时候) 在A类的main方法中去调用B类的method1方法,是根据B类的方
    法表去查找合适的方法,进行调用的。
    第二:运行时常量池(字符串常量池):从class中的常量池加载而来,JDK1.7之后,转移到堆中存

    字面量类型
    引用类型-->内存地址
    第三:JIT编译器编译之后的代码缓存

永久代和元空间的区别是什么?

1)JDK1.8之前使用的方法区实现是永久代,JDK1.8及以后使用的方法区实现是元空间。

2)存储位置不同:

永久代所使用的内存区域是JVM进程所使用的区域,它的大小受整个JVM的大小所限制。

元空间所使用的内存区域是物理内存区域。那么元空间的使用大小只会受物理内存大小的限

制。

3)存储内容不同:

永久代存储的信息基本上就是上面方法区存储内容中的数据。

元空间只存储类的元信息,而静态变量和运行时常量池都挪到堆中。

3)为什么要使用元空间来替换永久代?

字符串存在永久代中,容易出现性能问题和永久代内存溢出。

  • 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代
  • 溢出,太大则容易导致老年代溢出。
  • 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
  • Oracle 计划将HotSpot 与 JRockit 合二为一。

  • 下面来看几个例子搞明白这个变迁:
java 复制代码
import java.util.ArrayList;
import java.util.List;
public class StringDemo {
    public static void main(String[] args) {
        String base = "";
        List<String> strings = new ArrayList<String>();
        for (int i = 0; i < Integer.MAX_VALUE; i ++) {
            String str =  base  += "abcdefg";
            strings.add(str.intern());
        }
    }
}
bash 复制代码
java -XX:PermSize=8m -XX:MaxPermSize=8m StringDemo

在1.6的运行结果:

1.7运行结果:

永久带还保留但是字符串常量已经存到了堆中去,因为报的是堆内存溢出

1.8的运行结果:

可以看到1.8永久带彻底被移除,字符串常量存储到了堆内存中。

  1. 运行时常量

三种常量池的比较

class常量池:一个class文件只有一个class常量池

字面量:数值型(int、float、long、double)、双引号引起来的字符串值等

符号引用:Class、Method、Field等

运行时常量池:一个class对象有一个运行时常量池

字面量:数值型(int、float、long、double)、双引号引起来的字符串值等

符号引用:Class、Method、Field等

字符串常量池:全局只有一个字符串常量池

双引号引起来的字符串值

我们定义字符串的时候如果直接用""进行引用则是存储在字符串常量池中,如果用new String()则是存储在堆中。字符串常量池底层是一个拉链哈希表,用来存储和索引,所以字符串常量池不会存储重复的字符串。

看下面这几个例子来理解:

例子1

java 复制代码
String a = "abc";
String b = "abc";
// true 我们知道 == 比较的是存储地址,所以我们可以看到这两个字符串都是存储在字符串常量池中的,并且引用的是同一个地址,那么肯定是只存储了一份。
System.out.println(a == b);

例子2

java 复制代码
String a = "abc";
String b =  new String("abc");
// false b 这个对象是通过new出来的,他不会放到字符串常量池中
System.out.println(a == b);

例子3

java 复制代码
String a = "abc";
String c = "def";
String d = a + c;
String f = "abcdef";
// false 所以只有"" 的字符串在在字符串常量池,涉及到任何其它操作都不会存储在字符串常量池中
System.out.println(f == d);
// intern将字符串移动到字符串常量池中
// 因为我们调用函数intern d 的引用移动了字符串常量池,他们的地址也就一样了
System.out.println(f == d.intern());

例子4 map的底层实现和字符串常量池基本一样

java 复制代码
Map<String, Integer> map = new HashMap<>();
map.put("通话", 51);
map.put("重地", 55);
// 两个字符串的hashcode一模一样
System.out.println("通话".hashCode());
System.out.println("重地".hashCode());
// hashcode 相同的时候会进行拉链,也就是先比较hashcode如果相同在比较值,最后放到这个槽的链表上
System.out.println(map.size());

8)程序计数器

程序计数器(Program Counter Register),也叫PC寄存器,是一块较小的内存空间,它可以看作是当

前线程所执行的字节码指令的行号指示器。字节码解释器的工作就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。分支,循环,跳转,异常处理,线程回复等都需要依赖这个计数器来完

成。

为什么需要程序计数器?

由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(针对多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线

程切换(系统上下文切换)后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为"线程私有"的内存。

存储的什么数据?

如果一个线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是一个Native方法,这个计数器的值则为空。

异常:此内存区域是唯一一个在Java的虚拟机规范中没有规定任何OutOfMemoryError异常情况的区

域。

9)直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是Java 虚拟机规范中定义的内存区域。在JDK1.4 中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O 方式,

它可以使用native 函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer 对象

作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

本机直接内存的分配不会受到Java 堆大小的限制,受到本机总内存大小限制。

直接内存(堆外内存)与堆内存比较

直接内存申请空间耗费更高的性能,当频繁申请到一定量时尤为明显

直接内存IO读写的性能要优于普通的堆内存,在多次读写操作的情况下差异明显

下面这个例子可以比较它们读写之前的差异:

java 复制代码
public class ByteBufferCompare {
    public static void main(String[] args) {
        //allocateCompare(); //分配比较
        operateCompare(); //读写比较
    }
    /**
     * 直接内存 和 堆内存的 分配空间比较
     * 结论: 在数据量提升时,直接内存相比非直接内的申请,有很严重的性能问题
     */
    public static void allocateCompare() {
        int time = 1000 * 10000; //操作次数,1千万
        long st = System.currentTimeMillis();
        for (int i = 0; i < time; i++) {
             //ByteBuffer.allocate(int capacity) 分配一个新的字节缓冲区。
            //非直接内存分配申请
            ByteBuffer buffer = ByteBuffer.allocate(2);
        }
        long et = System.currentTimeMillis();
        System.out.println("在进行" + time + "次分配操作时,堆内存 分配耗时:" +
                (et - st) + "ms");
        long st_heap = System.currentTimeMillis();
        for (int i = 0; i < time; i++) {
            //ByteBuffer.allocateDirect(int capacity) 分配新的直接字节缓冲区。
            //直接内存分配申请
            ByteBuffer buffer = ByteBuffer.allocateDirect(2);

        }
        long et_direct = System.currentTimeMillis();
        System.out.println("在进行" + time + "次分配操作时,直接内存 分配耗时:" +
                (et_direct - st_heap) + "ms");
    }

    /**
     * 直接内存 和 堆内存的 读写性能比较
     * 结论:直接内存在直接的IO 操作上,在频繁的读写时 会有显著的性能提升
     */
    public static void operateCompare() {
        int time = 10 * 10000 * 10000; //操作次数,10亿
        ByteBuffer buffer = ByteBuffer.allocate(2 * time);
        long st = System.currentTimeMillis();
        for (int i = 0; i < time; i++) {
        // putChar(char value) 用来写入 char 值的相对 put 方法
            buffer.putChar('a');
        }
        buffer.flip();
        for (int i = 0; i < time; i++) {
            buffer.getChar();
        }
        long et = System.currentTimeMillis();
        System.out.println("在进行" + time + "次读写操作时,非直接内存读写耗时:" +
                (et - st) + "ms");
        ByteBuffer buffer_d = ByteBuffer.allocateDirect(2 * time);
        long st_direct = System.currentTimeMillis();
        for (int i = 0; i < time; i++) {
        // putChar(char value) 用来写入 char 值的相对 put 方法
            buffer_d.putChar('a');
        }
        buffer_d.flip();
        for (int i = 0; i < time; i++) {
            buffer_d.getChar();
        }
        long et_direct = System.currentTimeMillis();
        System.out.println("在进行" + time + "次读写操作时,直接内存读写耗时:" +
                (et_direct - st_direct) + "ms");
    }
}

从数据流的角度,来看:

非直接内存作用链:本地IO -->直接内存-->非直接内存-->直接内存-->本地IO

直接内存作用链:本地IO-->直接内存-->本地IO

直接内存的使用场景:

有很大的数据需要存储,它的生命周期很长

适合频繁的IO操作,例如:网络并发场景

相关推荐
rellvera4 分钟前
【强化学习的数学原理】第02课-贝尔曼公式-笔记
笔记·机器学习
Mephisto.java15 分钟前
【大数据学习 | Spark-Core】Spark提交及运行流程
大数据·学习·spark
PandaCave39 分钟前
vue工程运行、构建、引用环境参数学习记录
javascript·vue.js·学习
yuwinter44 分钟前
鸿蒙HarmonyOS学习笔记(2)
笔记·学习·harmonyos
red_redemption1 小时前
自由学习记录(23)
学习·unity·lua·ab包
幽兰的天空2 小时前
默语博主的推荐:探索技术世界的旅程
学习·程序人生·生活·美食·交友·美女·帅哥
游走于计算机中摆烂的2 小时前
启动前后端分离项目笔记
java·vue.js·笔记
沐泽Mu2 小时前
嵌入式学习-C嘎嘎-Day05
开发语言·c++·学习
你可以叫我仔哥呀2 小时前
ElasticSearch学习笔记三:基础操作(一)
笔记·学习·elasticsearch
maxiumII2 小时前
Diving into the STM32 HAL-----DAC笔记
笔记·stm32·嵌入式硬件