JVM虚拟机(一)基本介绍

 一、简介

  • JVM,全称 Java Virtual Machine,是 Java 程序的运行环境。 比如说我们自己写的代码想要运行的话,都必须在 JVM 中才能运行。当然严格来说,是 Java 的二进制字节码的运行环境。
  • ava 代码想要运行的话,就必须先经过编译之后,编译成 .class 文件才能运行。JVM 就是 .class 二进制字节码的运行环境。

 JVM 的好处:

  • 一次编写,到处运行: Java 是一个跨平台的语言,它是怎么跨平台呢?就是因为 JVM 给我们屏蔽了操作系统的差异。别管是在 Windows 或者是 Linux,真正运行代码的并不是这些系统,而是我们的 JVM。所以说才能做到一次编写到处运行。 
  • 自动内存管理,垃圾回收机制:般会跟 C语言进行对比,C语言需要程序员自己去管理内存,如果程序员由于编码不当,很容易造成内存泄露的问题。而 Java 虚拟机的垃圾回收功能就大大减轻了程序员的负担,减少了程序员出错的机会。

二、JVM体系结构  

 

  • 类加载器(ClassLoader):负责加载字节码文件(.class文件)加载到内存中,并生成出类的数据结构模板,存放在方法区。
  • 运行时数据区(RuntimeDataArea):是Java虚拟机在执行程序时为其分配和管理内存的一个逻辑区域,它负责存储和组织各个线程的执行信息、方法调用栈帧、对象实例以及类的结构数据等,以支持程序的运行。
  • 执行引擎(ExecutionEngine):也叫解释器,负责执行编译后的字节码指令,交由操作系统执行。
  • 本地库接口(NativeInterface):许Java程序调用本地方法(如C、C++方法),实现Java与本地代码的交互。 

2.1 程序计数器或PC寄存器(ProgramCounterRegister)

        一个运行中的Java程序,每当启动一个新线程时,都会为这个新线程创建一个自己私有的PC(程序计数器)寄存器。程序计数器的作用可以看做是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令;分支、循环、跳转、异常处理、线程恢复 等基础功能都需要依赖这个计数器来完成。如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Natvie方法,这个计数器值则为空(Undefined),所以Natvie方法不归java管;计数器占用的内存空间非常小,几乎可以忽略不记。不会发生内存溢出(OutOfMemory=OOM)错误。

作用:用来存储执行下一条指令的地址。

特点:是线程是私有的,不会存在内存溢出。

2.2 Java堆 

  • 堆(Heap)是jvm管理最大的一块内存空间,主要用于存放Java类的实例对象;通过new关键字创建的实例对象都会存放在堆内存;一个jvm实例只存在一个堆内存。堆的内存大小是可以调节的;
  • 特点是它是线程共享的,堆中的实例对象都需要考虑线程安全问题;有垃圾回收机制。 

堆在物理上分为两部分:新生代+老年代( jdk7之前分为:新生代+老年代+永久代 ); 1.8及以后把永久代移除堆内存了。移除堆内存之后改了名字为:元空间(Metaspace);也就是上面说的方法区(方法区就是元空间),元空间的大小默认就是主机运行内存的大小(可以调)。 

  •  年轻代被划分为三部分:Eden区和两个大小严格相同的 Survivor区,也叫幸存者区(S0、S1)。(根据 JVM 的策略,一个对象实例化后会先到 Eden区,假如对象在垃圾回收之后还存活,他就会被复制移动到 S0 或者 S1。假如在经过几次垃圾回收之后,对象依然存活于 Survivor区,它就会被放到老年代
  • 老年代主要指的是生命周期比较长的对象,一般是一些老的对象。 
  • 元空间: 主要作用是 用来保存类的信息,静态变量、常量,还有编译后的代码

  •   左边的是 Java7 的内存结构,右边是 Java 8 的内存结构,我们会发现 Java7 中的堆有一个叫部分做 “方法区/永久代”,但是在 Java8 中并没有。是这样的,到了 Java8 版本后,JVM 把 当前的 “方法去/永久代” 放到了本地内存,也就是元空间中。
  • 为什么要放到本地内存呢?是这样的,因为元空间或者说方法区中主要存储的是一些类或者常量,那么项目随着动态类加载的情况会越来越多,那么这块内存就会变得不可控: 如果内存分配小了,系统运行的过程中就会容易出现内存溢出;如果内存分配大了,又会导致浪费内存。
  • 所以说 Java8 之后就做了优化,现在都放到了本地内存,就是为了能够让堆去节省空间,防止内存溢出。其实我们最终的目的都是为了避免 OOM,防止内存溢出。

2.3 Java虚拟机栈 

Java虚拟机栈:Java Virtual machine Stacks,每个线程运行时所需要的内存,称为虚拟机栈,它的特点就是先进后出 

  • 每个线程运行的时候都会创建虚拟机栈,所以栈内存也是线程安全的。
  • 每个栈由多个栈帧(frame)组成,对应着每次方法调用时所需要的数据,或者说占用的内存。

 

如上图所示,栈帧里面就包含了方法的参数、局部变量、返回地址。如果当前方法调用了其他方法,就会对应有其他的栈帧。但是:每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法。 

假如栈帧1调用了栈帧2,栈帧2又调用了栈帧3,就会逐个进行压栈操作,最终如下所示:

当方法执行完毕之后,先是栈帧3弹栈,就会释放栈帧3的内存,其次是栈帧2,最后才是栈帧1,最终操作结果如下所示: 

  •  栈是在线程创建时创建的,它的生命周期跟随着线程的生命周期,线程结束栈内存也就被释放,对于栈来说不存在垃圾回收问题。
  • 栈的生命周期和线程一致,是线程私有的,所以线程是安全的(私有和公有判断线程是否安全)。
  • 当一个方法中调用了另一个方法时,那么这个栈中会存放这两个方法的栈帧内存,以此类推。
  • 栈的执行过程是:先进后出,后进先出;第一个产生的栈帧会放在最栈底,最后一个产生的栈帧会放在栈口;当所有栈帧执行完时,会从栈口开始释放内存。类似于弹夹中的子弹,第一颗被压进弹夹的子弹是最后射出的。
  • 每个线程或者栈只能有一个活动栈帧,也就是正在栈口执行中的栈帧叫做活动栈帧。
  • 栈管运行,堆管存储。方法体中的引用变量和基本类型的变量都在栈上,其他都在堆上(类的成员变量是对象的属性,所以也在堆中)。栈运行时存储什么:8种基本数据类型+引用类型+实例方法。

栈溢出一般有两个情况: 

  • 栈中栈帧过多,导致栈帧总和内存超过栈内存:一个方法无条件调用自己。
  • 栈帧过大,导致占内存溢出:json数据转换,json的data中无限出现数据。
  • 栈溢出属于错误,不属于异常。 

方法内的局部变量是否线程安全? 

  • 如果方法内局部变量没有逃离方法的作用范围,那么它是线程安全的。
  • 如果是局部变量引用了对象,并逃离方法的作用范围,则需要考虑线程安全。 
  • 如果局部变量逃逸了,线程就不安全。

举个例子:如下图所示,分别观察 m1()、m2()、m3() 中的 sb 变量是否存在线程安全问题? 

 

  • m1()方法: m1() 方法中的 sb 就是一个局部变量,然后在这里面添加了两个数据,1和2,最终打印了一下当前的数据就结束了。这种情况下,局部变量 sb 就是线程安全的。因为在 m1() 中,对于局部变量 sb 来说,每个线程来了以后,都会创建这么一个栈帧,那每个栈帧都会有这样一个局部变量 sb。即使我们操作了成千上万次,它也会去创建成千上万次,也就是说对于每个线程来说,局部变量 sb 都是独有的,所以说并没有线程安全问题。

  • m2()方法: m2() 方法中有一个行参 StringBuilder,然后往里面添加了1和2两个数据,最终也是打印了一下。它是线程安全的吗?并不是,虽然形参 sb 也是一个局部变量,但是在这个参数传递的过程当中,有可能会被其他线程调用,比如 main() 方法中就开启了一个新的线程来去调用 m2() 方法,同时 main() 方法中也有一个局部变量 sb,也就是说 main() 方法也在操作当前的局部变量,那么 main() 方法所对应的线程和 m2() 方法所对应的线程,多个线程在同时操作局部变量 sb 进行添加数据,两个线程共用了同一个局部变量,所以说不是线程安全的。

  • m3()方法: m3() 跟 m2() 的情况是一样的,它也不是线程安全的,它虽然没有记录形参,但是它会把局部变量进行返回,那么这个局部变量也有可能被其他线程公用,比如我们可以在 main() 方法中去调用 m3() 方法,得到局部变量 sb,然后在 main() 方法中开启多个线程同时去操作这个变量,那它也就成了多个线程共用的变量,那也就线程不安全了。 

堆和栈的区别是什么? 

  • 栈内存一般会用来存储局部变量和方法调用,但堆内存是用来存储 Java 对象和数组的。堆会GC垃圾回收,而栈不会。
  • 栈内存是线程私有的,而堆内存是线程共享的,要考虑线程安全的问题。 

2.4 本地方法栈(Native Method Stack) 

  • 本地⽅法栈⽤于管理本地⽅法的调⽤,本地⽅法栈也是线程私有的,具体做法是本地方法栈中登记native⽅法,在执行引擎执⾏时加载本地⽅法库。
  • Thread类中竟然有一个只有声明没有实现的方法,并使用native关键字。用native表示,也此方法是系统级(底层操作系统或第三方C语言)的,而不是语言级的,java并不能对其进行操作。native方法装载在本地方法栈(native method stack)中。 

2.5 方法区/元空间 

  • 方法区(Method Area) 是各个线程共享的内存区域(跟我们之前讲过的堆空间是一样的)。

  • 主要存储类的信息、运行时常量池。
  • 方法区是在虚拟机启动的时候创建,关闭虚拟机时释放元空间的内存。
  • 如果方法区域中的内存无法满足分配请求,则会抛出 OutOfMemoryError: Metaspace。

 

  • 方法区逻辑上是属于堆的一部分,但是不同的厂商存储的位置不太一样,我们目前都是用的 Oracle 提供的 HotSpot 编译器。在 JDK8 之前,方法区是存储在堆中一个叫永久代的存储区域中,但是在 JDK8 之后把永久代给移除了,换了一种实现,这种实现就叫元空间(Metaspace)。
  • 元空间就不在堆内存中了,它用的是本地内存,也就是操作系统的内存。为什么要挪动到这里呢,就是为了避免OOM。

元空间存储的信息: 

  • Class: 这个就是类的信息,包含了:类的结构、方法、字段等。 
  • Classloader: 这个是加载类的。
  • 运行常量池: 后面我们会专门介绍。

 常量池:

常量池 可以看作是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息。我们还是可以通过 javap 命令来查看字节码的结构,包含了三部分内容:类的基本信息、常量池、方法定义。

运行时常量池:

 常量池是 *.class 文件中的,当该类被加载,它的常量池信息就会被放入 运行时常量池,并把里面的 符号地址变为真实地址。我们前面提到的 #1、#2、#3 就是符号地址。

直接内存: 

不属于 JVM 中的内存结构,不由 JVM 进行管理,属于虚拟机所在操作系统的内存。常见于 NIO 操作时,用于数据缓冲区,它分配回收成本较高,但读写性能高


  • 常见IO读写流程分析:

 

  • 首先我们要知道,Java 本身并不支持磁盘读写的能力,它要调用磁盘读写的话必须调用我们操作系统提供的函数,这里就是调用本地的(native)方法。我们之前说过 native 修饰的方法都是操作系统提供的方法,Java 就是使用 native 修饰的方法来去操作磁盘文件。
  • 这里就涉及到了 CPU 的运行状态:用户态、内核态。当切换到内核态之后,这时候就由本地的函数去读取磁盘中的文件,读取到之后,会在操作系统中划出一块儿缓冲区,我们称之为 “系统缓冲区”。磁盘内容就会先读入到系统缓冲区中,它不可能把一个 300MB 的文件一次性读取到内存中,那样的话内存太紧张了,所以说它会利用缓冲区分批次地去读取。 这里要注意,这个 “系统缓冲区” 我们 Java 代码是不能够运行的。所以说 Java 会在堆中分配一块儿内存,Java 的缓冲区对应我们代码中的 new byte[]我们 Java 代码要想访问刚才读到的文件流数据,必须要从系统的缓冲区间接地读取到 Java 的缓冲区中。读入到 Java 缓冲区之后,进程就会进入到了下一个状态,我们再去调用输出流的写入操作,这样反复进行读取,我们的文件就能复制到目标的位置。

这里我们也发现了问题所在了:由于我们有两块缓冲区,也就是两块内存:一个是系统提供的系统缓冲区,第二个是 Java 中有一个 Java 的缓冲区。读取数据的时候就必然会涉及到数据要去存两份的问题,第一次先读取到系统中去,第二次才能读取到 Java 的缓冲区。因为我们 Java 代码本身是访问不到系统缓冲区的,我们必须要把它读取到 Java 缓冲区之后,才能对它进行操作。这里就造成了一次不必要的数据复制,因此效率就不是很高。

以上就是 常规IO 的操作,下面我们介绍一下 NIO 是怎么做的:

 

 这里面就用到了直接内存,也就是说在操作系统中划出了一块儿缓冲区,这块缓冲区和 常规IO 不一样的地方在于操作系统划分的内存,Java代码是可以访问的!换句话说,这块儿内存,系统可以访问它,Java代码也能够访问它。它是两端代码都可以共享的内存区域,这就是 直接内存

加入了直接内存之后,大家可以很明显地看出来,磁盘文件在读取的时候,Java代码操作起来就非常方便了。其实就是比我们刚才的代码少了一次缓冲区的复制操作,所以这个速度就得到了成倍的提升。这就是直接内存给我们带来的好处,它确实比较适合这种文件的IO操作。

  • 直接内存并不属于 JVM 的内存结构,不由 JVM 进行管理,是虚拟机所在的操作系统内存。
  • 直接内存常见于 NIO 操作时,用于数据缓冲区,分配回收成本较高,但读写性能高,不受 JVM 内存回收管理。 
  • 15
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Aplis

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值